1/** 2 * Objectifs Block (Mini-Notion) 3 * Remplaces original "Projects" block 4 */ 5 6import { objectifsStore } from '../../shared/objectifs_store.js'; 7import { pomodoroStore } from '../../shared/store_pomodoro.js'; 8import { i18n } from '../../shared/i18n.js'; 9import { supabase } from '../../shared/supabase.js?v=4'; 10import { NotionRichText } from '../../shared/notion_rich_text.js'; 11import { assertCompleteNotionSnapshot } from '../../shared/notion_sync_guard.mjs'; 12import { collectStaleNotionPageIds } from '../../shared/notion_page_cleanup.mjs'; 13import { 14 authenticatedNotionRequest, 15 disconnectNotionConnection, 16 loadNotionConnections, 17 sanitizeLegacyNotionStorage, 18 startNotionOAuth 19} from '../../shared/notion_oauth.mjs'; 20import { 21 getTodayPriorities, 22 addTaskToToday, 23 removeTaskFromToday, 24 reorderTodayPriorities, 25 setTaskGeneralPriority, 26 updateTaskEstimatedMinutes, 27 resolveTaskReference 28} from '../../shared/daily_priorities.js'; 29import { findPlannedTaskEvent } from '../../shared/task_calendar_planning.mjs'; 30import { getProjectActivationStep, PROJECT_ACTIVATION_STEP } from '../../shared/project_activation.mjs'; 31import { 32 clearActivationDraft, 33 consumeActivationDraft, 34 readActivationDraft, 35 readActivationDraftConsumption 36} from '../../shared/activation_draft.mjs?v=2'; 37import { 38 PLANNING_SESSION_DURATION_MS, 39 getPlanningSessionRemainingMs, 40 getPlanningSessionStorageKey, 41 sanitizePlanningSession, 42 startOrResumePlanningSession 43} from '../../shared/planning_session.mjs?v=1'; 44import { 45 clientPointToSelectionSpace, 46 clientRectToSelectionSpace, 47 copySelectedBlocks, 48 createSelectionRect, 49 createProjectDragItems, 50 formatSelectedBlocksAsText, 51 getRangeSelection, 52 getSelectionWithDescendants, 53 rectanglesIntersect 54} from '../../shared/project_multi_selection.mjs'; 55import { openAiPlanner } from './ai_planner.js'; 56 57let selectedPageId = localStorage.getItem('roxima_selected_project_page_id') || null; 58let projectBlockClipboard = []; 59 60function setSelectedPageId(id) { 61 selectedPageId = id; 62 if (id) { 63 localStorage.setItem('roxima_selected_project_page_id', id); 64 } else { 65 localStorage.removeItem('roxima_selected_project_page_id'); 66 } 67} 68 69const selectedPageIds = new Set(); 70const expandedPages = new Set(); 71 72// Slash Menu State 73let slashMenuOpen = false; 74let slashMenuIndex = 0; 75let slashMenuBlockId = null; // Block ID where / was typed 76let isPlusMenuMode = false; // New flag to distinguish context 77 78const SLASH_OPTIONS = [ 79 { label: 'Texte', labelKey: 'projects_block_text', type: 'p', icon: '¶' }, 80 { label: 'Titre 1', labelKey: 'projects_block_h1', type: 'h1', icon: 'H1' }, 81 { label: 'Titre 2', labelKey: 'projects_block_h2', type: 'h2', icon: 'H2' }, 82 { label: 'Action', labelKey: 'projects_block_todo', type: 'todo', icon: 'â' }, 83 { label: 'Liste', labelKey: 'projects_block_list', type: 'bulleted_list_item', icon: 'â¢' }, 84 { label: 'Liste numérotée', labelKey: 'projects_block_numbered_list', type: 'numbered_list_item', icon: '1.' }, 85 { label: 'Séparateur', labelKey: 'projects_block_divider', type: 'divider', icon: 'â' }, 86 { label: 'Page liée', labelKey: 'projects_block_link', type: 'page_link', icon: 'â' }, 87]; 88 89const optionLabel = (opt) => opt.labelKey ? i18n.t(opt.labelKey) : opt.label; 90 91function escapeHtml(value = '') { 92 const span = document.createElement('span'); 93 span.textContent = String(value); 94 return span.innerHTML; 95} 96 97function escapeAttribute(value = '') { 98 return String(value ?? '') 99 .replace(/&/g, '&') 100 .replace(/"/g, '"') 101 .replace(/'/g, ''') 102 .replace(/</g, '<') 103 .replace(/>/g, '>'); 104} 105 106function inlineEventString(value = '') { 107 return escapeAttribute(JSON.stringify(String(value))); 108} 109 110function renderProjectCreationActivation(draft = {}) { 111 const examples = [1, 2, 3].map((index) => ({ 112 index, 113 title: i18n.t(`project_activation_example_${index}_project`), 114 label: i18n.t(`project_activation_example_${index}_label`) 115 })); 116 const draftTitle = String(draft.title || ''); 117 const helpExpanded = draft.helpExpanded === true; 118 119 return ` 120 <section class="project-activation-card project-activation-card--create" aria-labelledby="project-activation-create-title"> 121 <div class="project-activation-kicker" data-i18n="project_activation_create_step">${i18n.t('project_activation_create_step')}</div> 122 <h3 id="project-activation-create-title" data-i18n="project_activation_create_title">${i18n.t('project_activation_create_title')}</h3> 123 <p data-i18n="project_activation_create_copy">${i18n.t('project_activation_create_copy')}</p> 124 <form class="project-activation-form" id="project-activation-form"> 125 <label class="projects-visually-hidden" for="project-activation-title" data-i18n="project_activation_start_label">${i18n.t('project_activation_start_label')}</label> 126 <input id="project-activation-title" name="project-title" type="text" maxlength="120" 127 value="${escapeAttribute(draftTitle)}" 128 placeholder="${escapeAttribute(i18n.t('project_activation_create_placeholder'))}" 129 data-i18n-placeholder="project_activation_create_placeholder" autocomplete="off" required> 130 <button class="project-activation-primary" type="submit" data-i18n="project_activation_create_cta">${i18n.t('project_activation_create_cta')}</button> 131 </form> 132 <small data-i18n="project_activation_create_hint">${i18n.t('project_activation_create_hint')}</small> 133 <button class="project-activation-help-toggle" id="project-activation-help-toggle" type="button" 134 aria-expanded="${helpExpanded}" aria-controls="project-activation-help">
135 <span data-i18n="project_activation_help_toggle">${i18n.t('project_activation_help_toggle')}</span> 136 <span class="project-activation-help-chevron" aria-hidden="true">â</span> 137 </button> 138 <div class="project-activation-help" id="project-activation-help"${helpExpanded ? '' : ' hidden'}> 139 <p data-i18n="project_activation_help_intro">${i18n.t('project_activation_help_intro')}</p> 140 <div class="project-activation-examples"> 141 ${examples.map((example) => ` 142 <button class="project-activation-example${draft.selectedExampleIndex === example.index ? ' is-selected' : ''}" type="button" 143 data-project-example-index="${example.index}" 144 data-project-example-title="${escapeAttribute(example.title)}" 145 aria-pressed="${draft.selectedExampleIndex === example.index}"> 146 ${escapeHtml(example.label)} 147 </button> 148 `).join('')} 149 </div> 150 </div> 151 </section> 152 `; 153} 154 155function renderFirstTaskActivation() { 156 return ` 157 <section class="project-activation-card project-activation-card--task" aria-labelledby="project-activation-task-title"> 158 <div class="project-activation-kicker" data-i18n="project_activation_task_step">${i18n.t('project_activation_task_step')}</div> 159 <h3 id="project-activation-task-title" data-i18n="project_activation_task_title">${i18n.t('project_activation_task_title')}</h3> 160 <p data-i18n="project_activation_task_copy">${i18n.t('project_activation_task_copy')}</p> 161 <button class="project-activation-primary" id="btn-add-first-task" type="button" data-i18n="project_activation_task_cta">${i18n.t('project_activation_task_cta')}</button> 162 <small data-i18n="project_activation_task_hint">${i18n.t('project_activation_task_hint')}</small> 163 </section> 164 `; 165} 166 167// Sidebar State (Local UI state) 168let sidebarForcedState = null; // 'open', 'closed', or null (default) 169let formattingToolbar = null; 170let currentColorDropdown = null; 171 172export function mount(container, ctx) { 173 const projectActivationDraft = { 174 title: '', 175 helpExpanded: false, 176 selectedExampleIndex: null 177 }; 178 let firstFocusActivationTaskId = null; 179 let activationDraftConsumptionPending = false; 180 let ignoredSelectionClick = null; 181 let notionOAuthStartPending = false; 182 let selectionPageId = selectedPageId; 183 const editorsCommittedBeforeFocusout = new WeakSet(); 184 let planningOwnerId = String(ctx.currentUser?.id || 'guest'); 185 let planningSession = loadPlanningSession(planningOwnerId); 186 let planningTimerId = null; 187 188 // Attach capturing click listener to container to intercept click event after drag-selection 189 container.addEventListener('click', (e) => { 190 if (!ignoredSelectionClick) return; 191 192 const clickToIgnore = ignoredSelectionClick; 193 ignoredSelectionClick = null; 194 const isSyntheticSelectionClick = performance.now() <= clickToIgnore.expiresAt 195 && Math.hypot(e.clientX - clickToIgnore.clientX, e.clientY - clickToIgnore.clientY) <= 6; 196 if (isSyntheticSelectionClick) { 197 e.preventDefault(); 198 e.stopImmediatePropagation(); 199 } 200 }, true); // useCapture = true! 201 202 // Inject Styles 203 if (!document.querySelector('link[href*="blocks/projects/styles.css"]')) { 204 const link = document.createElement('link'); 205 link.rel = 'stylesheet'; 206 link.href = '../blocks/projects/styles.css?v=22'; 207 document.head.appendChild(link); 208 } 209 210 monitorSelection(); 211 212 // Auth Helper 213 const store = objectifsStore; 214 const checkAuth = () => { 215 // Disabled auth block for onboarding and local usage 216 return true; 217 }; 218 const isTouchLayout = () => window.matchMedia('(max-width: 768px), (pointer: coarse)').matches; 219 const cleanTaskText = (block) => (block?.text || '').trim() || i18n.t('common_new_task'); 220 221 sanitizeLegacyNotionStorage(localStorage); 222 223 function getNotionWorkspaces() { 224 try { 225 return JSON.parse(localStorage.getItem('roxima_notion_workspaces') || '[]'); 226 } catch (e) { 227 return []; 228 } 229 } 230 231 function getActiveNotionWorkspaceId() { 232 return localStorage.getItem('roxima_notion_active_workspace_id'); 233 } 234 235 function getActiveNotionWorkspace() { 236 const list = getNotionWorkspaces(); 237 const activeId = getActiveNotionWorkspaceId(); 238 return list.find(w => w.id === activeId) || list[0] || null; 239 } 240 241 function isDemoNotionWorkspace(workspace) { 242 return workspace?.isDemo === true || workspace?.id === 'demo-workspace-id'; 243 } 244 245 async function refreshNotionConnections() { 246 if (!ctx.currentUser) return getNotionWorkspaces(); 247 248 const previous = getNotionWorkspaces(); 249 const connections = await loadNotionConnections({ 250 supabaseClient: supabase, 251 storage: localStorage 252 }); 253 254 // Les anciennes versions utilisaient l'identifiant de l'espace Notion 255 // comme identifiant local. Le serveur utilise désormais un connectionId. 256 // Réassocier les pages locales évite de les masquer après la migration. 257 const connectionByWorkspaceId = new Map( 258 connections.map(connection => [connection.workspaceId, connection]) 259 ); 260 const idMigration = new Map();
261 previous.forEach(workspace => { 262 const replacement = connectionByWorkspaceId.get(workspace.workspaceId || workspace.id); 263 if (replacement && replacement.id !== workspace.id) { 264 idMigration.set(workspace.id, replacement.id); 265 } 266 }); 267 268 if (idMigration.size > 0) { 269 store.getPages().forEach(page => { 270 const nextId = idMigration.get(page.notionWorkspaceId); 271 if (nextId) page.notionWorkspaceId = nextId; 272 }); 273 store.save(); 274 } 275 276 return connections; 277 } 278 279 function notionRequest(path, connectionId, body = {}, method = 'POST') { 280 return authenticatedNotionRequest(path, { 281 supabaseClient: supabase, 282 method, 283 body: { 284 ...body, 285 connectionId 286 } 287 }); 288 } 289 290 function setupNotionSyncInterval(ws) { 291 if (window.__notionSyncInterval) clearInterval(window.__notionSyncInterval); 292 if (!ws) return; 293 window.__notionSyncInterval = setInterval(() => { 294 if (!document.hidden && document.body.contains(container)) { 295 const currentWs = getActiveNotionWorkspace(); 296 if (currentWs) { 297 syncNotion(currentWs); 298 } 299 } 300 }, 30000); 301 } 302 303 function setActiveNotionWorkspace(workspaceId) { 304 localStorage.setItem('roxima_notion_active_workspace_id', workspaceId); 305 const active = getActiveNotionWorkspace(); 306 if (active) { 307 if (!isDemoNotionWorkspace(active)) { 308 syncNotion(active); 309 setupNotionSyncInterval(active); 310 } 311 } else { 312 if (window.__notionSyncInterval) { 313 clearInterval(window.__notionSyncInterval); 314 window.__notionSyncInterval = null; 315 } 316 } 317 } 318 319 async function removeNotionWorkspace(workspaceId) { 320 const workspace = getNotionWorkspaces().find(item => item.id === workspaceId); 321 if (!workspace) return; 322 323 if (!isDemoNotionWorkspace(workspace)) { 324 await disconnectNotionConnection(workspaceId, { supabaseClient: supabase }); 325 } 326 327 // Delete all Notion pages associated with this workspace 328 const store = objectifsStore; 329 const pagesToDelete = store.getPages().filter(p => (p.isNotion || p.source === 'notion') && p.notionWorkspaceId === workspaceId); 330 pagesToDelete.forEach(p => store.deletePage(p.id)); 331 332 let list = getNotionWorkspaces(); 333 list = list.filter(w => w.id !== workspaceId); 334 localStorage.setItem('roxima_notion_workspaces', JSON.stringify(list)); 335 336 const activeId = getActiveNotionWorkspaceId(); 337 if (activeId === workspaceId) { 338 if (list.length > 0) {
339 setActiveNotionWorkspace(list[0].id); 340 } else { 341 localStorage.removeItem('roxima_notion_active_workspace_id'); 342 } 343 } 344 } 345 346 async function syncBlockToNotion(pageId, blockId, overrides = null) { 347 const store = objectifsStore; 348 const page = store.getPage(pageId); 349 if (!page) return false; 350 if (!page.isNotion && page.source !== 'notion') return true; 351 352 // A daily priority can come from an inactive Notion workspace. 353 const notionWorkspace = page.notionWorkspaceId 354 ? getNotionWorkspaces().find(workspace => workspace.id === page.notionWorkspaceId) 355 : getActiveNotionWorkspace(); 356 if (!notionWorkspace) return false; 357 if (isDemoNotionWorkspace(notionWorkspace)) return true; 358 359 const storedBlock = store.getBlocks(pageId).find(b => b.id === blockId); 360 if (!storedBlock) return false; 361 const block = overrides ? { ...storedBlock, ...overrides } : storedBlock; 362 363 // If block has no notionBlockId yet, append it 364 if (!block.notionBlockId) { 365 try { 366 const parentId = page.notionPageId; 367 if (!parentId) return false; 368 369 const blocks = store.getBlocks(pageId) || []; 370 const index = blocks.findIndex(b => b.id === blockId); 371 const prevBlock = index > 0 ? blocks[index - 1] : null; 372 const afterBlockId = prevBlock && prevBlock.notionBlockId ? prevBlock.notionBlockId : null; 373 374 const data = await notionRequest('/api/notion/append-block', notionWorkspace.id, { 375 parentId: parentId, 376 type: block.type, 377 text: block.text, 378 rich_text: block.notionData ? block.notionData.rich_text : null, 379 checked: block.checked, 380 afterBlockId: afterBlockId 381 }); 382 383 if (data.newBlockId) { 384 storedBlock.notionBlockId = data.newBlockId; 385 storedBlock.isNotion = true; 386 store.save(); 387 } 388 return true; 389 } catch (error) { 390 console.error("Erreur syncBlockToNotion (append):", error); 391 return false; 392 } 393 } 394 395 try { 396 const blocks = store.getBlocks(pageId) || []; 397 const index = blocks.findIndex(b => b.id === blockId); 398 const prevBlock = index > 0 ? blocks[index - 1] : null; 399 const afterBlockId = prevBlock && prevBlock.notionBlockId ? prevBlock.notionBlockId : null; 400 401 const data = await notionRequest('/api/notion/update-block', notionWorkspace.id, { 402 blockId: block.notionBlockId, 403 type: block.type, 404 text: block.text, 405 rich_text: block.notionData ? block.notionData.rich_text : null, 406 checked: block.checked, 407 parentId: page.notionPageId, 408 afterBlockId: afterBlockId 409 }); 410 411 if (data.recreated && data.newBlockId) { 412 storedBlock.notionBlockId = data.newBlockId; 413 store.save(); 414 console.log(`â Recreated block in Notion due to type change: ${data.newBlockId}`); 415 } 416 return true; 417 } catch (error) { 418 console.error("Erreur syncBlockToNotion (update):", error); 419 ctx.bus?.emit('ui:toast', 'Erreur sauvegarde Notion'); 420 return false; 421 } 422 } 423 424 const GENERAL_PRIORITY_META = { 425 p1: { code: 'P1', labelKey: 'daily_priority_level_p1' }, 426 p2: { code: 'P2', labelKey: 'daily_priority_level_p2' }, 427 p3: { code: 'P3', labelKey: 'daily_priority_level_p3' }, 428 none: { code: 'â', labelKey: 'daily_priority_level_none' } 429 }; 430 431 function translateProject(key, variables = {}) { 432 return Object.entries(variables).reduce((text, [name, value]) => ( 433 text.replaceAll(`{${name}}`, String(value)) 434 ), i18n.t(key)); 435 } 436 437 function loadPlanningSession(ownerId) { 438 const storageKey = getPlanningSessionStorageKey(ownerId); 439 try { 440 const parsed = JSON.parse(localStorage.getItem(storageKey) || 'null'); 441 const normalized = sanitizePlanningSession(parsed, { ownerId }); 442 if (!normalized && parsed) localStorage.removeItem(storageKey); 443 return normalized; 444 } catch { 445 localStorage.removeItem(storageKey); 446 return null; 447 } 448 } 449 450 function savePlanningSession() { 451 const storageKey = getPlanningSessionStorageKey(planningOwnerId); 452 if (!planningSession) { 453 localStorage.removeItem(storageKey); 454 return; 455 } 456 localStorage.setItem(storageKey, JSON.stringify(planningSession)); 457 } 458 459 function formatPlanningTime(remainingMs) { 460 const totalSeconds = Math.max(0, Math.ceil(remainingMs / 1000)); 461 const minutes = Math.floor(totalSeconds / 60); 462 const seconds = totalSeconds % 60; 463 return `${String(minutes).padStart(2, '0')}
463:${String(seconds).padStart(2, '0')}`; 464 } 465 466 function renderPlanningSessionPanel() { 467 if (!planningSession) return ''; 468 const remainingMs = getPlanningSessionRemainingMs(planningSession); 469 if (remainingMs <= 0) return ''; 470 const durationMs = Date.parse(planningSession.endsAt) - Date.parse(planningSession.startedAt); 471 const progress = Math.max(0, Math.min(100, (remainingMs / durationMs) * 100)); 472 const time = formatPlanningTime(remainingMs); 473 474 return ` 475 <section class="planning-session-card" role="timer" aria-labelledby="planning-session-title" 476 aria-label="${escapeAttribute(translateProject('planning_session_time_remaining', { time }))}" 477 style="--planning-progress:${progress}%;"> 478 <span class="planning-session-card__mark" aria-hidden="true"> 479 <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"> 480 <circle cx="12" cy="12" r="9"></circle> 481 <path d="M12 7v5l3 2"></path> 482 </svg> 483 </span> 484 <span class="planning-session-card__copy"> 485 <small>${escapeHtml(i18n.t('planning_session_kicker'))}</small> 486 <strong id="planning-session-title">${escapeHtml(i18n.t('planning_session_title'))}</strong> 487 <span>${escapeHtml(i18n.t('planning_session_hint'))}</span> 488 </span> 489 <time class="planning-session-card__time" data-planning-session-time datetime="PT${Math.ceil(remainingMs / 1000)}S">${time}</time> 490 <button class="planning-session-card__finish" type="button" data-planning-session-finish> 491 ${escapeHtml(i18n.t('planning_session_finish'))} 492 </button> 493 <span class="planning-session-card__progress" aria-hidden="true"></span> 494 </section> 495 `; 496 } 497 498 function stopPlanningTimer() { 499 if (planningTimerId === null) return; 500 window.clearInterval(planningTimerId); 501 planningTimerId = null; 502 } 503 504 function completePlanningSession(reason = 'elapsed') { 505 if (!planningSession) return; 506 const completedSession = planningSession; 507 planningSession = null; 508 stopPlanningTimer(); 509 savePlanningSession(); 510 container.querySelector('.planning-session-card')?.remove(); 511 showDailyToast(i18n.t('planning_session_completed')); 512 513 queueMicrotask(() => { 514 ctx.bus?.emit('planning-session:completed', { 515 ownerId: completedSession.ownerId, 516 completedAt: new Date().toISOString(), 517 reason 518 }); 519 }); 520 } 521 522 function updatePlanningSessionUi() { 523 if (!planningSession) { 524 stopPlanningTimer(); 525 return; 526 } 527 528 const remainingMs = getPlanningSessionRemainingMs(planningSession); 529 if (remainingMs <= 0) { 530 completePlanningSession('elapsed'); 531 return; 532 } 533 534 const card = container.querySelector('.planning-session-card'); 535 const timeElement = card?.querySelector('[data-planning-session-time]'); 536 if (!card || !timeElement) return; 537 538 const durationMs = Date.parse(planningSession.endsAt) - Date.parse(planningSession.startedAt); 539 const progress = Math.max(0, Math.min(100, (remainingMs / durationMs) * 100)); 540 const time = formatPlanningTime(remainingMs); 541 card.style.setProperty('--planning-progress', `${progress}%`); 542 card.setAttribute('aria-label', translateProject('planning_session_time_remaining', { time })); 543 timeElement.textContent = time; 544 timeElement.setAttribute('datetime', `PT${Math.ceil(remainingMs / 1000)}S`); 545 } 546 547 function syncPlanningTimer() { 548 updatePlanningSessionUi(); 549 if (!planningSession || planningTimerId !== null) return; 550 planningTimerId = window.setInterval(updatePlanningSessionUi, 1000); 551 } 552 553 function startPlanningSession(payload = {}) { 554 const activeOwnerId = String(ctx.currentUser?.id || 'guest'); 555 const requestedOwnerId = String(payload.ownerId || activeOwnerId); 556 if (requestedOwnerId !== activeOwnerId) return; 557 558 planningOwnerId = activeOwnerId; 559 planningSession = startOrResumePlanningSession(planningSession, { 560 ownerId: planningOwnerId, 561 durationMs: PLANNING_SESSION_DURATION_MS 562 }); 563 savePlanningSession(); 564 render(); 565 syncPlanningTimer(); 566 } 567 568 function setPlanningOwner(user) { 569 const nextOwnerId = String(user?.id || 'guest'); 570 if (nextOwnerId === planningOwnerId) return; 571 stopPlanningTimer(); 572 planningOwnerId = nextOwnerId; 573 planningSession = loadPlanningSession(planningOwnerId); 574 if (container.closest('body')) { 575 render(); 576 syncPlanningTimer(); 577 } 578 } 579 580 function getGeneralPriorityLabel(meta) { 581 return i18n.t(meta.labelKey); 582 } 583 584 const dailyPriorityBusy = new Set(); 585 let priorityMenuElement = null; 586 let priorityMenuTrigger = null; 587 let priorityMenuCleanup = null; 588 let pendingDailyRender = false;
589 let pendingActivationRender = false; 590 591 function normalizeGeneralPriority(value) { 592 const normalized = String(value ?? '').trim().toLowerCase(); 593 if (normalized === '3' || normalized === 'high' || normalized === 'p1') return 'p1'; 594 if (normalized === '2' || normalized === 'medium' || normalized === 'p2') return 'p2'; 595 if (normalized === '1' || normalized === 'low' || normalized === 'p3') return 'p3'; 596 return 'none'; 597 } 598 599 function getDailyTaskId(item) { 600 return String(item?.queueTaskId ?? item?.queue_task_id ?? item?.taskId ?? item?.task_id ?? item?.sourceBlockId ?? item?.id ?? ''); 601 } 602 603 function normalizedIdentifier(value) { 604 return String(value ?? '').replace(/-/g, '').toLowerCase(); 605 } 606 607 function getDailyItems() { 608 try { 609 return [...(getTodayPriorities() || [])].sort((a, b) => Number(a.position || 0) - Number(b.position || 0)); 610 } catch (error) { 611 console.warn('Impossible de lire les priorités du jour :', error); 612 return []; 613 } 614 } 615 616 function isBlockInToday(block, items = getDailyItems()) { 617 const resolved = resolveTaskReference(block?.id); 618 const candidates = [block?.id, block?.notionBlockId, resolved?.taskId, resolved?.task_id] 619 .map(normalizedIdentifier) 620 .filter(Boolean); 621 return items.some(item => candidates.includes(normalizedIdentifier(getDailyTaskId(item)))); 622 } 623 624 function showDailyToast(message) { 625 ctx.bus?.emit('ui:toast', message); 626 } 627 628 function getDailyPlanningStatus(taskId) { 629 const events = ctx.store.get('rox_calendar_v1'); 630 const event = findPlannedTaskEvent(Array.isArray(events) ? events : [], taskId); 631 if (!event) return null; 632 const start = new Date(event.startISO || event.start); 633 const end = new Date(event.endISO || event.end); 634 if (Number.isNaN(start.getTime())) return null; 635 const time = start.toLocaleTimeString(i18n.locale(), { hour: '2-digit', minute: '2-digit' }); 636 const today = new Date(); 637 const sameDay = start.getFullYear() === today.getFullYear() 638 && start.getMonth() === today.getMonth() 639 && start.getDate() === today.getDate(); 640 const date = sameDay ? '' : `${start.toLocaleDateString(i18n.locale(), { day: 'numeric', month: 'short' })} · `; 641 const duration = Number.isNaN(end.getTime()) ? null : Math.max(1, Math.round((end - start) / 60000)); 642 return { 643 label: `${date}${time}${duration ? ` · ${duration} min` : ''}`, 644 eventId: event.id 645 }; 646 } 647 648 function renderDailyPrioritiesPanel() { 649 const items = getDailyItems(); 650 const listHtml = items.length ? items.map((item, index) => { 651 const taskId = getDailyTaskId(item); 652 const title = String(item.title || item.text || i18n.t('daily_priority_untitled_task')); 653 const projectName = String(item.projectName || item.project_name || i18n.t('common_project')); 654 const projectIcon = String(item.projectIcon || item.project_icon || ''); 655 const pomoSettings = (typeof pomodoroStore !== 'undefined' ? pomodoroStore.getSettings() : null) || {}; 656 const focusTime = Number(pomoSettings.focusTime) || 25; 657 const duration = Number(item.estimatedMinutes || item.estimated_minutes || item.duration_minutes || focusTime); 658 659 // Calculate base session duration and session multiplier 660 let sessionMinutes = focusTime; 661 let multiplier = 1; 662 if (duration % focusTime === 0) { 663 multiplier = Math.max(1, Math.floor(duration / focusTime)); 664 } else { 665 multiplier = Math.max(1, Math.round(duration / focusTime)); 666 sessionMinutes = Math.max(1, Math.round(duration / multiplier)); 667 } 668 const totalEstimated = sessionMinutes * multiplier; 669 670 const priority = normalizeGeneralPriority(item.generalPriority ?? item.general_priority ?? item.priority); 671 const meta = GENERAL_PRIORITY_META[priority]; 672 const priorityLabel = getGeneralPriorityLabel(meta); 673 const missing = Boolean(item.missing); 674 const planning = getDailyPlanningStatus(taskId); 675 const planningState = planning 676 ? `${i18n.t('daily_priority_planned')} ${planning.label}` 677 : i18n.t('daily_priority_unplanned'); 678 const totalDurationTitle = translateProject( 679 multiplier === 1 ? 'daily_priority_total_duration_one' : 'daily_priority_total_duration_many', 680 { total: totalEstimated, count: multiplier, minutes: sessionMinutes } 681 ); 682 return ` 683 <li class="daily-priority-item${missing ? ' is-missing' : ''}" data-daily-task-id="${escapeAttribute(taskId)}"> 684 <button class="daily-priority-drag" type="button" draggable="true" aria-label="${escapeAttribute(translateProject('daily_priority_drag', { task: title }))}" title="${escapeAttribute(i18n.t('daily_priority_drag_title'))}">â®â®</button> 685 <button class="daily-priority-position is-${priority}" type="button" data-daily-action="change-priority" aria-label="${escapeAttribute(translateProject('daily_priority_position', { position: index + 1, priority: priorityLabel }))}" title="${escapeAttribute(translateProject('daily_priority_edit_priority', { priority: priorityLabel }))}">${index + 1}</button> 686 <button class="daily-priority-open" type="button" data-daily-action="open" ${missing ? 'disabled' : ''}>
687 <span class="daily-priority-title">${escapeHtml(title)}</span> 688 <span class="daily-priority-project"> 689 ${escapeHtml(projectIcon)} ${escapeHtml(projectName)} 690 <span class="daily-priority-calendar-state${planning ? ' is-planned' : ''}"> 691 · ${escapeHtml(planningState)} 692 </span> 693 </span> 694 </button> 695 <div class="daily-priority-duration" title="${escapeAttribute(totalDurationTitle)}"> 696 <label class="daily-priority-duration-session" title="${escapeAttribute(i18n.t('daily_priority_focus_duration'))}"> 697 <span class="projects-visually-hidden">${escapeHtml(translateProject('daily_priority_focus_duration_for', { task: title }))}</span> 698 <input type="number" min="1" max="480" step="5" value="${sessionMinutes}" data-daily-session-duration ${missing ? 'disabled' : ''}> 699 <span>min</span> 700 </label> 701 <span class="daily-priority-duration-multiply" aria-hidden="true">Ã</span> 702 <label class="daily-priority-duration-multiplier" title="${escapeAttribute(i18n.t('daily_priority_session_count'))}"> 703 <span class="projects-visually-hidden">${escapeHtml(translateProject('daily_priority_session_count_for', { task: title }))}</span> 704 <input type="number" min="1" max="50" step="1" value="${multiplier}" data-daily-multiplier ${missing ? 'disabled' : ''}> 705 </label> 706 </div> 707 <div class="daily-priority-actions" aria-label="${escapeAttribute(translateProject('daily_priority_actions_for', { task: title }))}"> 708 <button type="button" data-daily-action="remove" aria-label="${escapeAttribute(translateProject('daily_priority_remove_from_today', { task: title }))}" title="${escapeAttribute(i18n.t('daily_priority_remove'))}">Ã</button> 709 </div> 710 </li> 711 `; 712 }).join('') : ` 713 <li class="daily-priorities-empty"> 714 ${escapeHtml(i18n.t('daily_priority_empty'))} 715 </li> 716 `; 717 718 return ` 719 <section class="daily-priorities-panel" aria-labelledby="daily-priorities-title"> 720 <header class="daily-priorities-header"> 721 <div> 722 <h3 id="daily-priorities-title">${escapeHtml(i18n.t('daily_priority_title'))} <span aria-hidden="true">·</span> ${items.length}/3</h3> 723 </div> 724 <button type="button" class="daily-priorities-plan-btn" data-action="plan-all-priorities" ${items.length === 0 ? 'disabled' : ''} title="${escapeAttribute(i18n.t('daily_priority_plan_all_title'))}"> 725 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles" aria-hidden="true"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="m5 3 1 2.5L8.5 6 6 7 5 9.5 4 7 1.5 6 4 5.5z"/><path d="m19 17 1 2.5 2.5.5-2.5 1-1 2.5-1-2.5-2.5-1 2.5-1z"/></svg> 726 ${escapeHtml(i18n.t('daily_priority_plan_all'))} 727 </button> 728 </header> 729 <ol class="daily-priorities-list">${listHtml}</ol> 730 </section> 731 `; 732 } 733 734 async function addBlockToToday(blockId) { 735 const busyKey = `add:${blockId}`; 736 if (dailyPriorityBusy.has(busyKey)) return null; 737 dailyPriorityBusy.add(busyKey); 738 try { 739 const result = await addTaskToToday(blockId); 740 showDailyToast(result?.status === 'already-added' 741 ? i18n.t('daily_priority_already_added_toast') 742 : i18n.t('daily_priority_added_toast')); 743 render(); 744 return result; 745 } catch (error) { 746 if (error?.code === 'MAX_PRIORITIES') {
747 showDailyToast(i18n.t('daily_priority_limit_toast')); 748 } else { 749 console.error('Erreur ajout priorité du jour :', error); 750 showDailyToast(i18n.t('daily_priority_add_error')); 751 } 752 return null; 753 } finally { 754 dailyPriorityBusy.delete(busyKey); 755 } 756 } 757 758 async function removeBlockFromToday(blockId) { 759 const busyKey = `remove:${blockId}`; 760 if (dailyPriorityBusy.has(busyKey)) return null; 761 dailyPriorityBusy.add(busyKey); 762 try { 763 const result = await removeTaskFromToday(blockId); 764 showDailyToast(i18n.t('daily_priority_removed_toast')); 765 render(); 766 return result; 767 } catch (error) { 768 console.error('Erreur retrait priorité du jour :', error); 769 showDailyToast(i18n.t('daily_priority_remove_error')); 770 return null; 771 } finally { 772 dailyPriorityBusy.delete(busyKey); 773 } 774 } 775 776 function readAndSaveEditableTask(blockId) { 777 const blockElement = container.querySelector(`.editor-block[data-id="${CSS.escape(String(blockId))}"]`); 778 const editor = blockElement?.querySelector('.editable-text'); 779 if (!editor) return ''; 780 781 const text = convertHtmlToMarkdown(editor.innerHTML).trim(); 782 if (!text) { 783 editor.focus(); 784 return ''; 785 } 786 787 const block = (store.getBlocks(selectedPageId) || []).find(item => item.id === blockId); 788 const notionData = { ...(block?.notionData || {}) }; 789 notionData.rich_text = NotionRichText.fromHtml(editor.innerHTML); 790 debouncedUpdateBlock.cancel(); 791 store.updateBlock(selectedPageId, blockId, { text, notionData }); 792 return text; 793 } 794 795 async function continueFirstTaskToFocus(button) { 796 const blockId = button.dataset.blockId; 797 const text = readAndSaveEditableTask(blockId); 798 if (!text) return; 799 800 button.disabled = true; 801 button.setAttribute('aria-busy', 'true'); 802 803 const alreadyInToday = isBlockInToday({ id: blockId }); 804 const result = alreadyInToday ? { status: 'already-added' } : await addBlockToToday(blockId); 805 if (!result) { 806 if (button.isConnected) { 807 button.disabled = false; 808 button.removeAttribute('aria-busy'); 809 } 810 return; 811 } 812 813 firstFocusActivationTaskId = null; 814 render(); 815 ctx.bus?.emit('app:push-task', { blockId, blockText: text }); 816 } 817 818 async function moveDailyPriority(taskId, delta) { 819 const items = getDailyItems(); 820 const orderedIds = items.map(getDailyTaskId); 821 const index = orderedIds.findIndex(id => normalizedIdentifier(id) === normalizedIdentifier(taskId)); 822 const nextIndex = index + delta; 823 if (index < 0 || nextIndex < 0 || nextIndex >= orderedIds.length) return; 824 [orderedIds[index], orderedIds[nextIndex]] = [orderedIds[nextIndex], orderedIds[index]]; 825 await reorderTodayPriorities(orderedIds); 826 render(); 827 } 828 829 function closeTaskPriorityMenu(restoreFocus = true) { 830 priorityMenuCleanup?.(); 831 priorityMenuCleanup = null; 832 priorityMenuTrigger?.setAttribute('aria-expanded', 'false'); 833 priorityMenuElement?.remove(); 834 priorityMenuElement = null; 835 if (restoreFocus && priorityMenuTrigger?.isConnected) priorityMenuTrigger.focus(); 836 priorityMenuTrigger = null; 837 } 838 839 function openTaskPriorityMenu(trigger, blockId) { 840 closeTaskPriorityMenu(false); 841 priorityMenuTrigger = trigger; 842 let current = 'none'; 843 const allPages = store.getPages ? store.getPages() : []; 844 let foundBlock = null; 845 for (const page of allPages) { 846 const blocks = store.getBlocks(page.id) || []; 847 foundBlock = blocks.find(item => String(item.id) === String(blockId)); 848 if (foundBlock) break; 849 } 850 if (foundBlock) { 851 current = normalizeGeneralPriority(foundBlock.generalPriority ?? foundBlock.general_priority ?? foundBlock.priority); 852 } else { 853 const dailyItems = getDailyItems() || []; 854 const dItem = dailyItems.find(it => String(it.taskId || it.id) === String(blockId)); 855 if (dItem) { 856 current = normalizeGeneralPriority(dItem.generalPriority ?? dItem.general_priority ?? dItem.priority); 857 } 858 } 859 const menu = document.createElement('div'); 860 menu.className = 'task-priority-menu'; 861 menu.setAttribute('role', 'menu'); 862 menu.setAttribute('aria-label', i18n.t('daily_priority_general')); 863 menu.innerHTML = Object.entries(GENERAL_PRIORITY_META).map(([value, meta]) => ` 864 <button type="button" role="menuitemradio" aria-checked="${current === value}" data-priority-value="${value}" class="is-${value}">
865 <span class="task-priority-menu-flag" aria-hidden="true">â</span> 866 <span><strong>${meta.code === 'â' ? escapeHtml(getGeneralPriorityLabel(meta)) : meta.code}</strong>${value === 'none' ? '' : ` â ${escapeHtml(getGeneralPriorityLabel(meta))}`}</span> 867 </button> 868 `).join(''); 869 document.body.appendChild(menu); 870 priorityMenuElement = menu; 871 trigger.setAttribute('aria-expanded', 'true'); 872 873 const rect = trigger.getBoundingClientRect(); 874 const left = Math.min(rect.left, window.innerWidth - menu.offsetWidth - 12); 875 const top = Math.min(rect.bottom + 6, window.innerHeight - menu.offsetHeight - 12); 876 menu.style.left = `${Math.max(12, left)}px`; 877 menu.style.top = `${Math.max(12, top)}px`; 878 879 menu.querySelectorAll('[data-priority-value]').forEach(button => { 880 button.addEventListener('click', async () => { 881 try { 882 await setTaskGeneralPriority(blockId, button.dataset.priorityValue); 883 ctx.bus?.emit('objectifs:refresh'); 884 closeTaskPriorityMenu(false); 885 render(); 886 } catch (error) { 887 console.error('Erreur priorité générale :', error); 888 showDailyToast(i18n.t('daily_priority_change_error')); 889 } 890 }); 891 }); 892 893 const closeOnPointer = event => { 894 if (!menu.contains(event.target) && event.target !== trigger) { 895 closeTaskPriorityMenu(false); 896 } 897 }; 898 const closeOnKey = event => { 899 if (event.key === 'Escape') { 900 event.preventDefault(); 901 closeTaskPriorityMenu(true); 902 return; 903 } 904 if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; 905 const options = [...menu.querySelectorAll('[role="menuitemradio"]')]; 906 const currentIndex = options.indexOf(document.activeElement); 907 let nextIndex = currentIndex; 908 if (event.key === 'Home') nextIndex = 0; 909 if (event.key === 'End') nextIndex = options.length - 1; 910 if (event.key === 'ArrowDown') nextIndex = (currentIndex + 1 + options.length) % options.length; 911 if (event.key === 'ArrowUp') nextIndex = (currentIndex - 1 + options.length) % options.length; 912 event.preventDefault(); 913 options[nextIndex]?.focus(); 914 }; 915 priorityMenuCleanup = () => { 916 document.removeEventListener('pointerdown', closeOnPointer, true); 917 document.removeEventListener('keydown', closeOnKey, true); 918 }; 919 document.addEventListener('pointerdown', closeOnPointer, true); 920 document.addEventListener('keydown', closeOnKey, true); 921 menu.querySelector('button')?.focus(); 922 } 923 924 function bindDailyPriorityUI() { 925 container.querySelectorAll('.first-focus-activation-button').forEach(button => { 926 button.addEventListener('click', event => { 927 event.preventDefault(); 928 event.stopPropagation(); 929 continueFirstTaskToFocus(button); 930 }); 931 }); 932 933 container.querySelectorAll('.add-to-today-button:not([disabled])').forEach(button => { 934 button.addEventListener('click', event => { 935 event.preventDefault(); 936 event.stopPropagation(); 937 button.disabled = true; 938 button.setAttribute('aria-busy', 'true'); 939 const isAdded = button.classList.contains('is-added'); 940 const action = isAdded 941 ? removeBlockFromToday(button.dataset.blockId) 942 : addBlockToToday(button.dataset.blockId); 943 action.finally(() => { 944 if (button.isConnected) { 945 button.disabled = false; 946 button.removeAttribute('aria-busy'); 947 } 948 }); 949 }); 950 }); 951 952 container.querySelectorAll('.task-priority-flag-button').forEach(button => { 953 button.addEventListener('click', event => { 954 event.preventDefault(); 955 event.stopPropagation(); 956 openTaskPriorityMenu(button, button.dataset.blockId); 957 }); 958 }); 959 960 const planAllBtn = container.querySelector('[data-action="plan-all-priorities"]'); 961 if (planAllBtn) { 962 planAllBtn.addEventListener('click', (e) => { 963 e.preventDefault(); 964 const request = { handled: false }; 965 ctx.bus?.emit('calendar:plan-all-priorities', request); 966 967 if (!request.handled) {
968 showDailyToast(i18n.t('daily_priority_calendar_unavailable')); 969 } else if (request.plannedCount > 0) { 970 showDailyToast(translateProject( 971 request.plannedCount === 1 ? 'daily_priority_slots_added_one' : 'daily_priority_slots_added_many', 972 { count: request.plannedCount } 973 )); 974 } else if (request.missingSlotCount > 0) { 975 showDailyToast(i18n.t('daily_priority_no_slots')); 976 } else if (request.removedCount > 0) { 977 showDailyToast(i18n.t('daily_priority_calendar_adjusted')); 978 } else { 979 showDailyToast(i18n.t('daily_priority_already_planned')); 980 } 981 }); 982 } 983 984 container.querySelectorAll('[data-daily-action]').forEach(button => { 985 button.addEventListener('click', async event => { 986 event.preventDefault(); 987 const row = button.closest('.daily-priority-item'); 988 const taskId = row?.dataset.dailyTaskId; 989 if (!taskId) return; 990 const busyKey = `${button.dataset.dailyAction}:${taskId}`; 991 if (dailyPriorityBusy.has(busyKey)) return; 992 dailyPriorityBusy.add(busyKey); 993 button.disabled = true; 994 try { 995 if (button.dataset.dailyAction === 'change-priority') { 996 openTaskPriorityMenu(button, taskId); 997 button.disabled = false; 998 dailyPriorityBusy.delete(busyKey); 999 return; 1000 } else if (button.dataset.dailyAction === 'remove') { 1001 await removeTaskFromToday(taskId); 1002 showDailyToast(i18n.t('daily_priority_removed_toast')); 1003 render(); 1004 } else if (button.dataset.dailyAction === 'up') { 1005 await moveDailyPriority(taskId, -1); 1006 } else if (button.dataset.dailyAction === 'down') { 1007 await moveDailyPriority(taskId, 1); 1008 } else if (button.dataset.dailyAction === 'open') { 1009 openDailyPrioritySource({ taskId }); 1010 } 1011 } catch (error) { 1012 console.error('Erreur priorité du jour :', error); 1013 showDailyToast(i18n.t('daily_priority_update_error')); 1014 render(); 1015 } finally { 1016 dailyPriorityBusy.delete(busyKey); 1017 } 1018 }); 1019 }); 1020 1021 container.querySelectorAll('[data-daily-session-duration], [data-daily-multiplier]').forEach(input => { 1022 input.addEventListener('change', async () => { 1023 const row = input.closest('.daily-priority-item'); 1024 const taskId = row?.dataset.dailyTaskId; 1025 if (!taskId) return; 1026 1027 const sessInput = row.querySelector('[data-daily-session-duration]'); 1028 const multInput = row.querySelector('[data-daily-multiplier]'); 1029 if (!sessInput || !multInput) return; 1030 1031 const sessVal = Math.max(1, Math.min(480, Number(sessInput.value) || 25)); 1032 const multVal = Math.max(1, Math.min(50, Number(multInput.value) || 1)); 1033 const totalMinutes = Math.min(1440, sessVal * multVal); 1034 1035 sessInput.disabled = true; 1036 multInput.disabled = true; 1037 try { 1038 await updateTaskEstimatedMinutes(taskId, totalMinutes); 1039 showDailyToast(translateProject( 1040 multVal === 1 ? 'daily_priority_duration_updated_one' : 'daily_priority_duration_updated_many', 1041 { count: multVal, minutes: sessVal, total: totalMinutes } 1042 )); 1043 render(); 1044 } catch (error) { 1045 console.error('Erreur durée estimée :', error);
1046 showDailyToast(i18n.t('daily_priority_duration_range_error')); 1047 render(); 1048 } 1049 }); 1050 }); 1051 1052 const list = container.querySelector('.daily-priorities-list'); 1053 if (!list) return; 1054 let draggedTaskId = null; 1055 list.querySelectorAll('.daily-priority-drag').forEach(handle => { 1056 handle.addEventListener('dragstart', event => { 1057 const row = handle.closest('.daily-priority-item'); 1058 draggedTaskId = row?.dataset.dailyTaskId || null; 1059 if (!draggedTaskId) { 1060 event.preventDefault(); 1061 return; 1062 } 1063 row.classList.add('is-dragging'); 1064 event.dataTransfer.effectAllowed = 'move'; 1065 event.dataTransfer.setData('text/plain', draggedTaskId); 1066 1067 const items = getDailyItems(); 1068 const item = items.find(it => getDailyTaskId(it) === draggedTaskId); 1069 if (item) { 1070 event.dataTransfer.setData('application/json', JSON.stringify({ 1071 type: 'todo-item', 1072 id: draggedTaskId, 1073 sourceId: draggedTaskId, 1074 text: item.title || item.text || i18n.t('daily_priority_untitled_task'), 1075 estimatedMinutes: Number(item.estimatedMinutes || item.estimated_minutes || 25) 1076 })); 1077 window.__roximaDraggingTask = { 1078 text: item.title || item.text || i18n.t('daily_priority_untitled_task'), 1079 id: draggedTaskId, 1080 origin: 'daily-priorities' 1081 }; 1082 } 1083 }); 1084 handle.addEventListener('dragend', () => { 1085 list.querySelectorAll('.daily-priority-item').forEach(row => row.classList.remove('is-dragging', 'is-drop-target')); 1086 draggedTaskId = null; 1087 window.__roximaDraggingTask = null; 1088 }); 1089 }); 1090 list.addEventListener('dragover', event => { 1091 if (draggedTaskId) { 1092 const target = event.target.closest('.daily-priority-item'); 1093 if (!target || target.dataset.dailyTaskId === draggedTaskId) return; 1094 event.preventDefault(); 1095 event.dataTransfer.dropEffect = 'move'; 1096 list.querySelectorAll('.daily-priority-item').forEach(row => row.classList.toggle('is-drop-target', row === target)); 1097 } else { 1098 // Dragging from editor 1099 const isProjectDrag = event.dataTransfer.types.includes('application/json') || window.__roximaDraggingTask; 1100 if (isProjectDrag) { 1101 event.preventDefault(); 1102 event.dataTransfer.dropEffect = 'copy'; 1103 list.classList.add('is-drag-over'); 1104 } 1105 } 1106 }); 1107 list.addEventListener('dragleave', () => { 1108 list.classList.remove('is-drag-over'); 1109 }); 1110 list.addEventListener('drop', async event => { 1111 event.preventDefault(); 1112 list.classList.remove('is-drag-over'); 1113 if (draggedTaskId) { 1114 const target = event.target.closest('.daily-priority-item'); 1115 if (!target) return; 1116 const targetId = target.dataset.dailyTaskId; 1117 const orderedIds = getDailyItems().map(getDailyTaskId).filter(id => normalizedIdentifier(id) !== normalizedIdentifier(draggedTaskId)); 1118 let insertIndex = orderedIds.findIndex(id => normalizedIdentifier(id) === normalizedIdentifier(targetId)); 1119 if (insertIndex < 0) return; 1120 if (event.clientY > target.getBoundingClientRect().top + target.offsetHeight / 2) insertIndex += 1; 1121 orderedIds.splice(insertIndex, 0, draggedTaskId); 1122 try { 1123 await reorderTodayPriorities(orderedIds); 1124 render(); 1125 } catch (error) { 1126 console.error('Erreur réorganisation priorités :', error);
1127 showDailyToast(i18n.t('daily_priority_reorder_error')); 1128 render(); 1129 } finally { 1130 draggedTaskId = null; 1131 } 1132 } else { 1133 // Drop from editor 1134 let blockId = null; 1135 if (window.__roximaDraggingTask && window.__roximaDraggingTask.id) { 1136 blockId = window.__roximaDraggingTask.id; 1137 } else { 1138 const dragData = event.dataTransfer.getData('text/plain'); 1139 if (dragData && dragData.length > 20) { 1140 blockId = dragData; 1141 } 1142 } 1143 if (blockId) { 1144 await addBlockToToday(blockId); 1145 } 1146 } 1147 }); 1148 } 1149 1150 function findTaskSource(task) { 1151 const pageId = task?.sourcePageId || task?.source_page_id || task?.projectId || task?.project_id; 1152 const blockId = task?.sourceBlockId || task?.source_block_id || task?.blockId || task?.block_id; 1153 if (pageId && blockId) { 1154 const block = (store.getBlocks(pageId) || []).find(item => 1155 normalizedIdentifier(item.id) === normalizedIdentifier(blockId) 1156 || normalizedIdentifier(item.notionBlockId) === normalizedIdentifier(blockId) 1157 ); 1158 if (block) return { pageId, block }; 1159 } 1160 1161 const candidateId = task?.taskId || task?.task_id || task?.id; 1162 for (const page of store.getPages()) { 1163 const block = (store.getBlocks(page.id) || []).find(item => 1164 normalizedIdentifier(item.id) === normalizedIdentifier(candidateId) 1165 || normalizedIdentifier(item.notionBlockId) === normalizedIdentifier(candidateId) 1166 ); 1167 if (block) return { pageId: page.id, block }; 1168 } 1169 return null; 1170 } 1171 1172 function openDailyPrioritySource(payload = {}) { 1173 const request = payload?.task || payload; 1174 const requestedId = request?.taskId || request?.task_id || request?.sourceBlockId || request?.id; 1175 const task = resolveTaskReference(requestedId) || request; 1176 const source = findTaskSource(task); 1177 if (!source) { 1178 showDailyToast(i18n.t('daily_priority_source_missing')); 1179 return; 1180 } 1181 1182 const page = store.getPage(source.pageId); 1183 if (page?.notionWorkspaceId && page.notionWorkspaceId !== getActiveNotionWorkspaceId()) { 1184 const workspace = getNotionWorkspaces().find(item => item.id === page.notionWorkspaceId); 1185 localStorage.setItem('roxima_notion_active_workspace_id', page.notionWorkspaceId); 1186 if (workspace && !isDemoNotionWorkspace(workspace)) setupNotionSyncInterval(workspace); 1187 } 1188 setSelectedPageId(source.pageId); 1189 ctx.bus?.emit('app:show-panel', { panelId: 'region-projects' }); 1190 render({ preserveCaret: false }); 1191 requestAnimationFrame(() => focusBlock(source.block.id, 'end')); 1192 } 1193 1194 async function handleDailyTaskExecutionRequest(payload, executionStatus) { 1195 const request = payload?.task || payload || {}; 1196 const requestedId = request.taskId || request.task_id || request.sourceBlockId || request.id; 1197 let source = null; 1198 try { 1199 const task = resolveTaskReference(requestedId) || request; 1200 source = findTaskSource(task); 1201 if (!source) throw new Error('La tâche source est introuvable.'); 1202 const patch = executionStatus === 'done' 1203 ? { checked: true, executionStatus: 'done', execution_status: 'done' } 1204 : { checked: false, executionStatus: 'blocked', execution_status: 'blocked' }; 1205 // Sync a detached payload: the shared store must stay untouched 1206 // until Notion acknowledges the update, even if another save runs 1207 // while the request is in flight. 1208 const synced = await syncBlockToNotion(source.pageId, source.block.id, patch); 1209 if (!synced) throw new Error('La synchronisation de la tâche a échoué.'); 1210 store.updateBlock(source.pageId, source.block.id, patch); 1211 ctx.bus?.emit('objectifs:refresh'); 1212 const result = { status: executionStatus, task: resolveTaskReference(requestedId) || task }; 1213 payload?.resolve?.(result); 1214 payload?.onSuccess?.(result); 1215 payload?.callback?.(null, result); 1216 } catch (error) { 1217 console.error(`Erreur passage de tâche à lâétat ${executionStatus} :`, error);
1218 showDailyToast(i18n.t('daily_priority_task_update_error')); 1219 payload?.reject?.(error); 1220 payload?.onError?.(error); 1221 payload?.callback?.(error); 1222 } 1223 } 1224 1225 async function deleteBlockFromNotion(pageId, notionBlockId) { 1226 const page = store.getPage(pageId); 1227 const notionWorkspace = page?.notionWorkspaceId 1228 ? getNotionWorkspaces().find(workspace => workspace.id === page.notionWorkspaceId) 1229 : getActiveNotionWorkspace(); 1230 if (!notionWorkspace || isDemoNotionWorkspace(notionWorkspace)) return; 1231 1232 try { 1233 await notionRequest('/api/notion/delete-block', notionWorkspace.id, { 1234 blockId: notionBlockId 1235 }); 1236 } catch (error) { 1237 console.error("Erreur deleteBlockFromNotion:", error); 1238 } 1239 } 1240 1241 async function syncNotion(activeWorkspace, { revealImportedPage = false } = {}) { 1242 if (!activeWorkspace) { 1243 window.Roxima?.showToast?.("Aucun environnement Notion connecté."); 1244 return; 1245 } 1246 1247 if (isDemoNotionWorkspace(activeWorkspace)) return; 1248 1249 try { 1250 window.Roxima?.showToast?.(`Synchronisation de ${activeWorkspace.name}...`); 1251 1252 const store = objectifsStore; 1253 // The automatic Notion refresh can run while a task is still being 1254 // edited. Push that live value first so the following pull cannot 1255 // replace it with the older remote title. 1256 const focusedEditor = container.querySelector('.editable-text:focus'); 1257 if (focusedEditor && selectedPageId) { 1258 const committed = commitEditableBlockNow(store, selectedPageId, focusedEditor); 1259 if (committed) { 1260 debouncedSyncBlockToNotion.cancel(); 1261 const savedRemotely = await syncBlockToNotion(selectedPageId, committed.id); 1262 if (!savedRemotely) { 1263 throw new Error('La tâche en cours ne peut pas être synchronisée sans risque de perte.'); 1264 } 1265 } 1266 } 1267 1268 // Wait for any pending focusout / local debounced changes to save 1269 await new Promise(resolve => setTimeout(resolve, 350)); 1270 1271 const previousNotionPageIds = new Set( 1272 store.getPages() 1273 .filter(page => page.notionWorkspaceId === activeWorkspace.id && page.notionPageId) 1274 .map(page => page.notionPageId) 1275 ); 1276 const pages = store.getPages(); 1277 1278 // Sync any newly created blocks that haven't been pushed to Notion yet 1279 for (const p of pages) { 1280 if (p.isNotion && p.notionWorkspaceId === activeWorkspace.id) { 1281 const blocks = store.getBlocks(p.id) || []; 1282 for (const block of blocks) { 1283 if (!block.notionBlockId) { 1284 await syncBlockToNotion(p.id, block.id); 1285 } 1286 } 1287 } 1288 } 1289 1290 const data = await notionRequest('/api/notion/sync-pages', activeWorkspace.id); 1291 1292 assertCompleteNotionSnapshot(data); 1293 1294 if (data.pages && Array.isArray(data.pages)) { 1295 const idMap = {}; // notionPageId -> roximaPageId 1296 1297 const existingPages = store.getPages(); 1298 existingPages.forEach(p => { 1299 if (p.notionPageId) { 1300 idMap[p.notionPageId] = p.id; 1301 } 1302 }); 1303 1304 data.pages.forEach(item => { 1305 if (idMap[item.id]) { 1306 const page = store.getPage(idMap[item.id]); 1307 if (page) { 1308 page.title = item.title; 1309 page.icon = item.icon; 1310 page.notionLastEditedTime = item.lastEditedTime; 1311 page.isNotion = true; 1312 page.source = 'notion'; 1313 page.notionWorkspaceId = activeWorkspace.id; 1314 } 1315 } else { 1316 const newPage = store.createPage(item.title, null, item.icon); 1317 newPage.notionPageId = item.id; 1318 newPage.notionLastEditedTime = item.lastEditedTime;
1319 newPage.isNotion = true; 1320 newPage.source = 'notion'; 1321 newPage.notionWorkspaceId = activeWorkspace.id; 1322 idMap[item.id] = newPage.id; 1323 } 1324 }); 1325 1326 data.pages.forEach(item => { 1327 const roximaPageId = idMap[item.id]; 1328 if (roximaPageId) { 1329 const page = store.getPage(roximaPageId); 1330 if (page) { 1331 if (item.parentId && idMap[item.parentId]) { 1332 page.parentId = idMap[item.parentId]; 1333 } else { 1334 page.parentId = null; 1335 } 1336 } 1337 } 1338 }); 1339 1340 // Clean up only pages that are positively identified as stale 1341 // Notion pages for this workspace. A manual project may share a 1342 // title and parent with a Notion page; that is valid user data 1343 // and must never be treated as a duplicate. 1344 const syncedNotionPageIds = new Set(data.pages.map(item => item.id)); 1345 const pagesToDelete = new Set(collectStaleNotionPageIds( 1346 store.getPages(), 1347 syncedNotionPageIds, 1348 activeWorkspace.id 1349 )); 1350 1351 pagesToDelete.forEach(id => store.deletePage(id)); 1352 store.save(); 1353 1354 // Group incoming Notion blocks by page 1355 const notionBlocksByPage = {}; 1356 data.pages.forEach(item => { 1357 const roximaPageId = idMap[item.id]; 1358 if (roximaPageId) notionBlocksByPage[roximaPageId] = []; 1359 }); 1360 if (data.todoBlocks && Array.isArray(data.todoBlocks)) { 1361 data.todoBlocks.forEach(block => { 1362 const roximaPageId = idMap[block.pageId]; 1363 if (!roximaPageId) return; 1364 if (!notionBlocksByPage[roximaPageId]) { 1365 notionBlocksByPage[roximaPageId] = []; 1366 } 1367 notionBlocksByPage[roximaPageId].push(block); 1368 }); 1369 } 1370 1371 let localTodos = ctx.store.get('todos') || []; 1372 let localTodosChanged = false; 1373 const deletedNotionBlocks = []; 1374 1375 // Sync blocks page by page 1376 for (const roximaPageId in notionBlocksByPage) { 1377 const incomingBlocks = notionBlocksByPage[roximaPageId]; 1378 const page = store.getPage(roximaPageId); 1379 const pageTitle = page ? store.getPagePath(page.id) : ''; 1380 const pageIcon = page ? page.icon : 'ð'; 1381 1382 const currentBlocks = store.getBlocks(roximaPageId) || []; 1383 const newBlocksList = []; 1384 const incomingNotionIds = new Set(incomingBlocks.map(block => normalizedIdentifier(block.id))); 1385 1386 currentBlocks.forEach(block => { 1387 if ((block.isNotion || block.notionBlockId) && block.notionBlockId && 1388 !incomingNotionIds.has(normalizedIdentifier(block.notionBlockId))) { 1389 deletedNotionBlocks.push({ 1390 type: 'block-deleted', 1391 pageId: roximaPageId, 1392 blockId: block.id, 1393 notionBlockId: block.notionBlockId 1394 }); 1395 } 1396 }); 1397 1398 incomingBlocks.forEach(incoming => { 1399 const blockType = incoming.type || 'todo'; 1400 let blockText = incoming.text; 1401 if (blockType === 'page_link') { 1402 blockText = idMap[incoming.text] || incoming.text; 1403 } 1404 1405 // Try to find existing block by notionBlockId 1406 let existing = currentBlocks.find(b => b.notionBlockId === incoming.id); 1407 1408 if (existing) { 1409 existing.text = blockText; 1410 existing.checked = incoming.completed; 1411 existing.type = blockType; 1412 existing.isNotion = true; 1413 newBlocksList.push(existing); 1414 } else { 1415 // Create new block 1416 const newId = 'block_' + Math.random().toString(36).substring(2, 9); 1417 const newBlock = { 1418 id: newId, 1419 type: blockType, 1420 text: blockText, 1421 checked: incoming.completed, 1422 level: 0, 1423 notionBlockId: incoming.id, 1424 isNotion: true 1425 }; 1426 newBlocksList.push(newBlock); 1427 } 1428 1429 const blockIdForTodo = existing ? existing.id : newBlocksList[newBlocksList.length - 1].id; 1430 1431 // Sync to To-Do block list if it's a todo type 1432 if (blockType === 'todo') { 1433 let existingLocalTodo = localTodos.find(t => t.sourceId === blockIdForTodo || t.notionBlockId === incoming.id); 1434 if (existingLocalTodo) { 1435 if (existingLocalTodo.text !== incoming.text || existingLocalTodo.completed !== incoming.completed || existingLocalTodo.projectName !== pageTitle || existingLocalTodo.projectIcon !== pageIcon || existingLocalTodo.sourceId !== blockIdForTodo) { 1436 existingLocalTodo.text = incoming.text; 1437 existingLocalTodo.completed = incoming.completed; 1438 existingLocalTodo.projectName = pageTitle; 1439 existingLocalTodo.projectIcon = pageIcon; 1440 existingLocalTodo.sourceId = blockIdForTodo; 1441 existingLocalTodo.notionBlockId = incoming.id; 1442 localTodosChanged = true; 1443 } 1444 } else { 1445 localTodos.push({ 1446 id: 'todo_' + Math.random().toString(36).substring(2, 9), 1447 text: incoming.text, 1448 completed: incoming.completed, 1449 sourceId: blockIdForTodo, 1450 projectId: roximaPageId, 1451 projectName: pageTitle, 1452 projectIcon: pageIcon, 1453 level: 0, 1454 source: 'notion', 1455 notionBlockId: incoming.id 1456 }); 1457 localTodosChanged = true; 1458 } 1459 } 1460 }); 1461 1462 // Keep local blocks that are NOT Notion blocks (not yet synced) 1463 currentBlocks.forEach(b => { 1464 if (!b.isNotion && !b.notionBlockId) { 1465 newBlocksList.push(b); 1466 } 1467 }); 1468 1469 // Overwrite the blocks list in the store for this page 1470 store.state.blocksByPageId[roximaPageId] = newBlocksList; 1471 } 1472 1473 if (localTodosChanged) { 1474 ctx.store.set('todos', localTodos); 1475 } 1476 1477 store.save(); 1478 1479 let revealedPage = null; 1480 if (revealImportedPage && data.pages.length > 0) { 1481 const preferredNotionPage = data.pages.find(item => !previousNotionPageIds.has(item.id)) || data.pages[0]; 1482 const preferredPageId = idMap[preferredNotionPage.id]; 1483 if (preferredPageId) { 1484 setSelectedPageId(preferredPageId); 1485 selectedPageIds.clear(); 1486 selectedPageIds.add(String(preferredPageId)); 1487 sidebarForcedState = 'open'; 1488 revealedPage = store.getPage(preferredPageId); 1489 } 1490 } 1491 1492 deletedNotionBlocks.forEach(change => { 1493 ctx.bus.emit('objectifs:changed', { state: store.state, change }); 1494 }); 1495 render(); 1496 ctx.bus.emit('objectifs:refresh'); 1497 if (revealImportedPage) { 1498 window.Roxima.notionOAuthReturn = false;
1499 ctx.bus.emit('notion:sync:complete', { 1500 pageCount: data.pages.length, 1501 pageTitle: revealedPage?.title || '' 1502 }); 1503 } 1504 window.Roxima?.showToast?.(translateProject( 1505 data.pages.length === 1 ? 'notion_sync_complete_one' : 'notion_sync_complete_many', 1506 { count: data.pages.length } 1507 )); 1508 return { pageCount: data.pages.length, pageId: revealedPage?.id || null }; 1509 } 1510 } catch (err) { 1511 console.error(err); 1512 const message = err.code === 'NOTION_AUTH_FAILED' 1513 ? 'Connexion Notion a renouveler. Les donnees locales ont ete conservees.' 1514 : err.message; 1515 window.Roxima?.showToast?.('Erreur Sync Notion: ' + message); 1516 if (revealImportedPage) { 1517 window.Roxima.notionOAuthReturn = false; 1518 ctx.bus.emit('notion:sync:error', { message }); 1519 } 1520 return { pageCount: 0, pageId: null, error: err }; 1521 } 1522 } 1523 1524 function showNotionConfigModal() { 1525 const workspaces = getNotionWorkspaces(); 1526 const activeWorkspaceId = getActiveNotionWorkspaceId(); 1527 1528 let workspacesHtml = ''; 1529 if (workspaces.length === 0) { 1530 workspacesHtml = ` 1531 <div style="text-align:center; padding:20px; border:1px dashed var(--border-color, #eee); border-radius:8px; background:rgba(0,0,0,0.01);"> 1532 <p style="margin:0 0 10px 0; font-size:0.9rem; color:var(--text-muted);">Aucun environnement Notion connecté.</p> 1533 </div> 1534 `; 1535 } else { 1536 workspacesHtml = workspaces.map(w => { 1537 const isActive = w.id === activeWorkspaceId; 1538 const safeWorkspaceId = escapeHtml(w.id); 1539 return ` 1540 <div class="workspace-item" style="display:flex; align-items:center; justify-content:space-between; padding:10px 12px; background:rgba(0,0,0,0.02); border-radius:8px; border:1px solid ${isActive ? 'var(--text-main, #000)' : 'rgba(0,0,0,0.06)'}; margin-bottom:8px;"> 1541 <label style="display:flex; align-items:center; gap:10px; cursor:pointer; flex:1; margin:0;"> 1542 <input type="radio" name="notion-workspace-radio" value="${safeWorkspaceId}" ${isActive ? 'checked' : ''} style="margin:0; width:16px; height:16px;"> 1543 <img src="assets/img/Logo_Notion.png" width="16" height="16" alt="Notion" style="object-fit:contain; flex-shrink:0;"> 1544 <span style="font-weight:600; font-size:0.9rem; color:var(--text-main);">${escapeHtml(w.name)}</span> 1545 </label> 1546 <button class="mini-btn btn-delete-workspace" data-id="${safeWorkspaceId}" style="background:none; border:none; color:var(--text-muted, #888); padding:6px; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; border-radius:4px;" title="Déconnecter" aria-label="Déconnecter"> 1547 <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg> 1548 </button> 1549 </div> 1550 `; 1551 }).join(''); 1552 } 1553 1554 const content = ` 1555 <div class="notion-modal-config" style="display:flex; flex-direction:column; gap:16px; padding:10px 0;"> 1556 <p style="font-size:0.9rem; color:var(--text-muted); margin:0;"> 1557 Choisissez l'environnement Notion à afficher et à synchroniser avec vos projets ROXIMA. 1558 </p> 1559 1560 <div class="notion-workspaces-list"> 1561 ${workspacesHtml} 1562 </div> 1563 1564 <div style="display:flex; justify-content:space-between; align-items:center; margin-top:16px; gap:10px;"> 1565 <button id="modal-btn-add-notion-env" class="cal-btn" style="display:flex; align-items:center; gap:8px; padding:8px 14px; background:none; border:1px solid var(--border-color, #eee); border-radius:8px; font-size:0.85rem; font-weight:600; cursor:pointer; color:var(--text-main);"> 1566 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"></line><line x1="5" y1="12" x2="19" y2="12"></line></svg>
1567 <span>Ajouter un environnement</span> 1568 </button> 1569 ${workspaces.length > 0 ? ` 1570 <button id="modal-btn-sync-active-notion" class="cal-btn" style="display:flex; align-items:center; gap:8px; padding:8px 16px; background:var(--text-main, #000); color:var(--bg-card, #fff); border:none; border-radius:8px; font-size:0.85rem; font-weight:600; cursor:pointer;"> 1571 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21.5 2v6h-6M2.5 22v-6h6"></path><path d="M2 11.5a10 10 0 0 1 18.8-4.3L21.5 8M22 12.5a10 10 0 0 1-18.8 4.3L2.5 16"></path></svg> 1572 <span>Synchroniser</span> 1573 </button> 1574 ` : ''} 1575 </div> 1576 </div> 1577 `; 1578 1579 ctx.bus.emit('ui:modal:open', { 1580 title: "Configuration Notion", 1581 content: content 1582 }); 1583 1584 const modalContainer = document.querySelector('.modal'); 1585 if (modalContainer) { 1586 modalContainer.querySelectorAll('input[name="notion-workspace-radio"]').forEach(radio => { 1587 radio.addEventListener('change', (e) => { 1588 const wsId = e.target.value;
1589 setActiveNotionWorkspace(wsId); 1590 render(); 1591 showNotionConfigModal(); 1592 }); 1593 }); 1594 1595 modalContainer.querySelectorAll('.btn-delete-workspace').forEach(btn => { 1596 btn.addEventListener('click', async (e) => { 1597 e.stopPropagation(); 1598 const wsId = e.currentTarget.dataset.id; 1599 const ws = workspaces.find(w => w.id === wsId); 1600 if (ws && confirm(`Déconnecter l'environnement Notion "${ws.name}" ?`)) { 1601 try { 1602 await removeNotionWorkspace(wsId); 1603 render(); 1604 showNotionConfigModal(); 1605 } catch (error) { 1606 console.error('Erreur deconnexion Notion :', error); 1607 window.Roxima?.showToast?.(error?.message || 'Impossible de deconnecter Notion.'); 1608 } 1609 } 1610 }); 1611 }); 1612 1613 const btnAddEnv = modalContainer.querySelector('#modal-btn-add-notion-env'); 1614 if (btnAddEnv) { 1615 btnAddEnv.addEventListener('click', async () => { 1616 if (notionOAuthStartPending) return; 1617 notionOAuthStartPending = true; 1618 btnAddEnv.disabled = true; 1619 btnAddEnv.setAttribute('aria-busy', 'true'); 1620 const closeBtn = document.querySelector('#modal-close-btn'); 1621 if (closeBtn) closeBtn.click(); 1622 try { 1623 await startNotionOAuth({ supabaseClient: supabase }); 1624 } catch (error) { 1625 console.error('Erreur demarrage OAuth Notion :', error); 1626 window.Roxima?.showToast?.(error?.message || 'Impossible de connecter Notion.'); 1627 } finally { 1628 notionOAuthStartPending = false; 1629 if (btnAddEnv.isConnected) { 1630 btnAddEnv.disabled = false; 1631 btnAddEnv.removeAttribute('aria-busy'); 1632 } 1633 } 1634 }); 1635 } 1636 1637 const btnSync = modalContainer.querySelector('#modal-btn-sync-active-notion'); 1638 if (btnSync) { 1639 btnSync.addEventListener('click', async () => { 1640 const closeBtn = document.querySelector('#modal-close-btn'); 1641 if (closeBtn) closeBtn.click(); 1642 1643 const activeWorkspace = getActiveNotionWorkspace(); 1644 if (activeWorkspace) { 1645 await syncNotion(activeWorkspace); 1646 } 1647 }); 1648 } 1649 } 1650 } 1651 1652 const openMobileProjectActions = (blockId) => { 1653 document.activeElement?.blur(); 1654 const pageId = selectedPageId; 1655 if (!pageId) return; 1656 1657 const block = store.getBlocks(pageId).find(item => String(item.id) === String(blockId)); 1658 if (!block) return; 1659 1660 const text = cleanTaskText(block); 1661 1662 let priorityHtml = ''; 1663 if (block.type === 'todo') { 1664 const currentPriority = normalizeGeneralPriority(block.generalPriority ?? block.general_priority ?? block.priority); 1665 priorityHtml = ` 1666 <div class="mobile-general-priority" role="group" aria-label="${escapeAttribute(i18n.t('daily_priority_general'))}"> 1667 <p>${escapeHtml(i18n.t('daily_priority_general'))}</p> 1668 ${Object.entries(GENERAL_PRIORITY_META).map(([value, meta]) => ` 1669 <button class="priority-flag-btn is-${value}" data-priority="${value}" type="button" aria-pressed="${currentPriority === value}"> 1670 <span aria-hidden="true">â</span> 1671 <span>${meta.code === 'â' ? escapeHtml(getGeneralPriorityLabel(meta)) : `${meta.code} · ${escapeHtml(getGeneralPriorityLabel(meta))}`}</span> 1672 </button> 1673 `).join('')} 1674 </div> 1675 `; 1676 } 1677 1678 ctx.bus.emit('ui:action-sheet:open', { 1679 title: text, 1680 subtitleKey: 'common_actions', 1681 customHtml: priorityHtml, 1682 actions: [ 1683 ...(block.type === 'todo' && !block.checked ? [{ 1684 icon: isBlockInToday(block) ? 'â' : 'â', 1685 labelKey: isBlockInToday(block) ? 'daily_priority_already_selected' : 'daily_priority_add_action', 1686 detailKey: isBlockInToday(block) ? 'daily_priority_already_selected_detail' : 'daily_priority_max_tasks_detail', 1687 handler: () => addBlockToToday(block.id) 1688 }] : []), 1689 { 1690 icon: 'ð¯', 1691 labelKey: 'projects_menu_focus', 1692 handler: () => ctx.bus.emit('app:push-task', { blockId: block.id, blockText: text }) 1693 }, 1694 { 1695 icon: 'â', 1696 labelKey: 'projects_action_send_todo', 1697 detailKey: 'projects_action_send_todo_detail', 1698 handler: () => { 1699 const project = store.getPage(pageId); 1700 ctx.bus.emit('quick:add', { text, sourceId: block.id, projectId: pageId, projectName: project ? store.getPagePath(project.id) : '', projectIcon: project ? project.icon : '' }); 1701 ctx.bus.emit('app:show-panel', { panelId: 'region-todo' }); 1702 ctx.bus.emit('ui:toast', i18n.t('projects_toast_sent_todo')); 1703 } 1704 }, 1705 { 1706 icon: 'â', 1707 labelKey: 'projects_menu_plan', 1708 handler: () => { 1709 const resolvedTask = resolveTaskReference(block.id); 1710 ctx.bus.emit('calendar:plan-task', { 1711 text, 1712 sourceId: resolvedTask?.taskId || resolvedTask?.task_id || block.notionBlockId || block.id, 1713 durationMinutes: Number(block.estimatedMinutes || block.estimated_minutes || 25) 1714 }); 1715 } 1716 }, 1717 { 1718 icon: 'â', 1719 labelKey: 'common_edit', 1720 handler: () => focusBlock(block.id, 'end') 1721 }, 1722 { 1723 icon: 'Ã', 1724 labelKey: 'common_delete', 1725 danger: true, 1726 handler: () => { 1727 store.deleteBlock(pageId, block.id); 1728 render(); 1729 if (ctx.bus) ctx.bus.emit('objectifs:refresh'); 1730 } 1731 } 1732 ] 1733 }); 1734 1735 if (block.type === 'todo') { 1736 setTimeout(() => { 1737 const modalEl = document.getElementById('modal-layer'); 1738 if (modalEl) { 1739 modalEl.querySelectorAll('.priority-flag-btn').forEach(btn => { 1740 const btnPriority = btn.dataset.priority; 1741 if (btnPriority === normalizeGeneralPriority(block.generalPriority ?? block.general_priority ?? block.priority)) { 1742 btn.classList.add('active'); 1743 } 1744 1745 btn.addEventListener('click', async () => { 1746 try { 1747 await setTaskGeneralPriority(block.id, btnPriority); 1748 if (ctx.bus) ctx.bus.emit('objectifs:refresh'); 1749 document.getElementById('modal-close-btn')?.click(); 1750 render(); 1751 } catch (error) { 1752 console.error('Erreur priorité générale :', error);
1753 showDailyToast(i18n.t('daily_priority_change_error')); 1754 } 1755 }); 1756 }); 1757 } 1758 }, 80); 1759 } 1760 }; 1761 1762 // Expose Bridge and Handlers 1763 window.Roxima = window.Roxima || {}; 1764 1765 // Global handler for direct access 1766 window.Roxima.handlePlusClick = (e, blockId) => { 1767 if (e) e.stopPropagation(); 1768 if (!checkAuth()) return; // Guard Plus Menu 1769 1770 const btn = e.target.closest('.block-plus'); // Ensure we get the div if clicked on text 1771 if (btn) { 1772 console.log("Direct Plus Click:", blockId); 1773 openMenu(btn, blockId); // Unified openMenu 1774 } 1775 }; 1776 1777 1778 // Global Mousedown to handle Menu Interaction preventing Blur 1779 if (!window.Roxima._menuMousedownListenerAdded) { 1780 document.addEventListener('mousedown', (e) => { 1781 const menuItem = e.target.closest('.slash-item'); 1782 if (menuItem) { 1783 e.preventDefault(); // Prevent input blur 1784 e.stopPropagation(); 1785 const type = menuItem.dataset.type; 1786 if (isPlusMenuMode) { 1787 handlePlusAction(type); 1788 } else { 1789 applySlashType(selectedPageId, type); 1790 } 1791 } 1792 }); 1793 window.Roxima._menuMousedownListenerAdded = true; 1794 } 1795 1796 function handleContainerClick(e) { 1797 // 1. Menu Item Click (Handled by Mousedown mainly, but keep for fallback if needed?) 1798 // If mousedown handled it, we don't need it here. 1799 if (e.target.closest('.slash-item')) return; 1800 1801 const btnAddFirstTask = e.target.closest('#btn-add-first-task'); 1802 if (btnAddFirstTask) { 1803 e.preventDefault(); 1804 if (!checkAuth() || !selectedPageId) return; 1805 1806 const firstTask = store.addBlock(selectedPageId, { type: 'todo', text: '' }); 1807 firstFocusActivationTaskId = firstTask.id; 1808 render(); 1809 window.setTimeout(() => { 1810 const taskInput = Array.from(container.querySelectorAll('.editable-text')) 1811 .find(input => input.dataset.id === firstTask.id); 1812 taskInput?.focus(); 1813 }, 50); 1814 return; 1815 } 1816 1817 // 1.5 Clear Selection 1818 if (!e.target.closest('.editor-block') && !e.target.closest('.block-handle') && !e.target.closest('.block-plus')) { 1819 if (selection.ids.size > 0) selection.clear(); 1820 } 1821 1822 // 2. Empty State / Bottom Trigger 1823 if (e.target.closest('.editor-empty-state') || 1824 e.target.closest('#bottom-add-trigger') || 1825 e.target.id === 'editor-scroller' || 1826 e.target.id === 'editor-blocks') { 1827 1828 if (!checkAuth()) return; // Guard 1829 1830 store.addBlock(selectedPageId, { type: 'p', text: '' }); 1831 render(); 1832 setTimeout(() => { 1833 const inputs = container.querySelectorAll('.editable-text'); 1834 if (inputs.length) inputs[inputs.length - 1].focus(); 1835 }, 50); 1836 return; 1837 } 1838 1839 // 3. Toggle Sidebar 1840 const toggleBtn = e.target.closest('#btn-toggle-sidebar'); 1841 if (toggleBtn) { 1842 const layoutEl = container.querySelector('.obj-layout'); 1843 if (layoutEl) { 1844 const isVisible = layoutEl.classList.contains('show-sidebar') || 1845 (!layoutEl.classList.contains('hide-sidebar') && !!container.closest('.card-expanded')); 1846 if (isVisible) { 1847 layoutEl.classList.remove('show-sidebar'); 1848 layoutEl.classList.add('hide-sidebar'); 1849 sidebarForcedState = 'closed'; 1850 if (toggleBtn) { 1851 toggleBtn.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>`; 1852 } 1853 } else { 1854 layoutEl.classList.remove('hide-sidebar'); 1855 layoutEl.classList.add('show-sidebar'); 1856 sidebarForcedState = 'open'; 1857 if (toggleBtn) { 1858 toggleBtn.innerHTML = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false"><polyline points="15 18 9 12 15 6"></polyline></svg>`; 1859 } 1860 } 1861 } 1862 return; 1863 } 1864 1865 // 4. Block Handle Click -> Context Menu 1866 const mobileTaskAction = e.target.closest('.mobile-task-action'); 1867 if (mobileTaskAction) { 1868 e.preventDefault(); 1869 e.stopPropagation(); 1870 if (!checkAuth()) return; 1871 openMobileProjectActions(mobileTaskAction.dataset.id); 1872 return; 1873 } 1874 1875 // 4. Block Handle Click -> Context Menu 1876 const handle = e.target.closest('.block-handle'); 1877 if (handle) { 1878 e.stopPropagation(); 1879 if (!checkAuth()) return; // Guard 1880 const blockId = handle.closest('.editor-block').dataset.id; 1881 openContextMenu(handle, blockId); 1882 return; 1883 } 1884 1885 // The deletion logic is now handled in the modal confirmation click listener. 1886 1887 // 5. Sidebar Actions (Delete/Add) - Delegated from Container 1888 const btnDel = e.target.closest('.action-del'); 1889 if (btnDel) { 1890 e.stopPropagation(); 1891 if (!checkAuth()) return; // Guard 1892 const id = btnDel.dataset.id; 1893 1894 const isMulti = selectedPageIds.has(id) && selectedPageIds.size > 1; 1895 const hasSubProjects = store.getPages().some(p => p.parentId === id); 1896 1897 const warningText = isMulti 1898 ? `Vous allez supprimer <b>${selectedPageIds.size} projets</b> et tous leurs sous-projets. Continuer ?` 1899 : (hasSubProjects 1900 ? "Ce projet contient des sous-projets. Tu veux vraiment le supprimer ?" 1901 : "Voulez-vous vraiment supprimer cette page et tout son contenu ?"); 1902
1903 // Custom Modal for Confirmation 1904 ctx.bus.emit('ui:modal:open', { 1905 title: 'Supprimer la page ?', 1906 content: ` 1907 <div style="text-align:center; padding:10px;"> 1908 <p style="margin-bottom:20px;">${warningText}</p> 1909 <div style="display:flex; justify-content:center; gap:10px;"> 1910 <button id="modal-btn-cancel-del" style="padding:8px 16px; border:1px solid #ddd; background:white; border-radius:6px; cursor:pointer;">Annuler</button> 1911 <button id="modal-btn-confirm-del" style="padding:8px 16px; border:none; background:#ff4d4f; color:white; border-radius:6px; cursor:pointer;">Supprimer</button> 1912 </div> 1913 </div> 1914 ` 1915 }); 1916 1917 setTimeout(() => { 1918 const btnCancel = document.getElementById('modal-btn-cancel-del'); 1919 const btnConfirm = document.getElementById('modal-btn-confirm-del'); 1920 1921 if (btnCancel) { 1922 btnCancel.addEventListener('click', () => { 1923 document.getElementById('modal-close-btn')?.click(); 1924 }); 1925 } 1926 1927 if (btnConfirm) { 1928 btnConfirm.addEventListener('click', () => { 1929 if (selectedPageIds.has(id) && selectedPageIds.size > 1) { 1930 const idsToDelete = Array.from(selectedPageIds); 1931 idsToDelete.forEach(pId => store.deletePage(pId)); 1932 selectedPageIds.clear(); 1933 setSelectedPageId(null); 1934 } else { 1935 store.deletePage(id); 1936 selectedPageIds.delete(id); 1937 if (selectedPageId === id) setSelectedPageId(null); 1938 } 1939 render(); 1940 document.getElementById('modal-close-btn')?.click(); 1941 }); 1942 } 1943 }, 10); 1944 return; 1945 } 1946 1947 const btnAddSub = e.target.closest('.action-add-sub'); 1948 if (btnAddSub) { 1949 e.stopPropagation(); 1950 if (!checkAuth()) return; // Guard 1951 const id = btnAddSub.dataset.id; 1952 const newPage = store.createPage(i18n.t('common_untitled'), id); 1953 expandedPages.add(id); 1954 setSelectedPageId(newPage.id); 1955 selectedPageIds.clear(); 1956 selectedPageIds.add(selectedPageId); 1957 render(); 1958 return; 1959 } 1960 1961 const toggleBtnFolder = e.target.closest('.toggle-folder-btn'); 1962 if (toggleBtnFolder) { 1963 e.stopPropagation(); 1964 const id = toggleBtnFolder.dataset.id; 1965 if (expandedPages.has(id)) { 1966 expandedPages.delete(id); 1967 } else { 1968 expandedPages.add(id); 1969 } 1970 render(); 1971 return; 1972 } 1973 1974 1975 // 5.5 NEW: Sidebar Main Add Button 1976 const btnAddPage = e.target.closest('#btn-add-page') || e.target.closest('#btn-add-page-empty'); 1977 if (btnAddPage) { 1978 if (!checkAuth()) return; // Guard 1979 const newPage = store.createPage(i18n.t('common_untitled'), null); 1980 setSelectedPageId(newPage.id); 1981 render(); 1982 window.setTimeout(() => { 1983 const titleInput = container.querySelector('.page-title-editor'); 1984 if (!titleInput || titleInput.dataset.id !== newPage.id) return; 1985 titleInput.focus(); 1986 titleInput.select(); 1987 }, 50); 1988 return; 1989 } 1990 1991 // Select Page (Last priority in sidebar) 1992 const pageItem = e.target.closest('.page-item'); 1993 if (pageItem) { 1994 // Avoid selecting if we clicked an action button (already handled, but good to be safe) 1995 if (!e.target.closest('.mini-btn')) { 1996 const id = pageItem.dataset.id; 1997 1998 if (e.ctrlKey || e.metaKey) { 1999 if (selectedPageIds.has(id)) { 2000 selectedPageIds.delete(id); 2001 if (selectedPageId === id) { 2002 setSelectedPageId(selectedPageIds.size > 0 ? Array.from(selectedPageIds).pop() : null); 2003 } 2004 } else { 2005 selectedPageIds.add(id); 2006 setSelectedPageId(id); 2007 } 2008 } else if (e.shiftKey && selectedPageId) { 2009 const allItems = Array.from(document.querySelectorAll('.page-item')); 2010 const startIdx = allItems.findIndex(el => el.dataset.id === selectedPageId); 2011 const endIdx = allItems.findIndex(el => el.dataset.id === id); 2012 if (startIdx !== -1 && endIdx !== -1) { 2013 const min = Math.min(startIdx, endIdx); 2014 const max = Math.max(startIdx, endIdx); 2015 selectedPageIds.clear(); 2016 for (let i = min; i <= max; i++) { 2017 selectedPageIds.add(allItems[i].dataset.id); 2018 } 2019 setSelectedPageId(id); 2020 } 2021 } else { 2022 selectedPageIds.clear(); 2023 selectedPageIds.add(id); 2024 setSelectedPageId(id); 2025 } 2026 render(); 2027 } 2028 return; 2029 } 2030 2031 // 6. Close Menus on outside click 2032 if (slashMenuOpen) { 2033 closeSlashMenu(); 2034 } 2035 } 2036 2037 // Attach global listener once to container 2038 container.removeEventListener('click', handleContainerClick); 2039 container.addEventListener('click', handleContainerClick); 2040 2041 // Attach global listener to document for menus (since they are now on body) 2042 if (!window.Roxima._menuClickListenerAdded) { 2043 document.addEventListener('click', (e) => { 2044 const slashItem = e.target.closest('.slash-item'); 2045 if (slashItem) { 2046 e.stopPropagation(); 2047 const type = slashItem.dataset.type; 2048 if (isPlusMenuMode) { 2049 handlePlusAction(type); 2050 } else { 2051 applySlashType(selectedPageId, type); 2052 } 2053 } 2054 }); 2055 window.Roxima._menuClickListenerAdded = true; 2056 } 2057 2058 // Checkbox Change Listener (for Todo sync) 2059 container.addEventListener('change', (e) => { 2060 if (e.target.classList.contains('todo-check')) { 2061 const blockEl = e.target.closest('.editor-block'); 2062 const blockId = blockEl.dataset.id; 2063 const checked = e.target.checked; 2064 store.updateBlock(selectedPageId, blockId, { checked }); 2065 2066 // Visual update 2067 const textEl = blockEl.querySelector('.editable-text'); 2068 if (checked) textEl.classList.add('checked-text'); 2069 else textEl.classList.remove('checked-text'); 2070 2071 // Emit Update Signal 2072 if (ctx.bus) ctx.bus.emit('objectifs:refresh'); 2073 } 2074 }); 2075 2076 function saveProjectScrollState() { 2077 const saved = []; 2078 if (!container) return saved; 2079 const selectors = ['.obj-content', '.obj-sidebar', '.v2-section-content', '.region-projects', '.editor-block-list'];
2080 selectors.forEach(sel => { 2081 const el = container.querySelector(sel) || container.closest(sel); 2082 if (el && (el.scrollTop > 0 || el.scrollLeft > 0)) { 2083 saved.push({ selector: sel, top: el.scrollTop, left: el.scrollLeft }); 2084 } 2085 }); 2086 return saved; 2087 } 2088 2089 function restoreProjectScrollState(saved) { 2090 if (!saved || !saved.length || !container) return; 2091 saved.forEach(item => { 2092 const el = container.querySelector(item.selector) || container.closest(item.selector); 2093 if (el) { 2094 el.scrollTop = item.top; 2095 el.scrollLeft = item.left; 2096 } 2097 }); 2098 } 2099 2100 function findActivationTask(taskId) { 2101 const normalizedTaskId = String(taskId || ''); 2102 for (const page of store.getPages()) { 2103 const block = store.getBlocks(page.id).find((item) => String(item.id) === normalizedTaskId); 2104 if (block) return { page, block }; 2105 } 2106 return null; 2107 } 2108 2109 function ensureActivationDraftAction({ actionText, pageId, taskId }) { 2110 const existing = findActivationTask(taskId); 2111 if (existing) { 2112 if (String(existing.block.text || '').trim() !== actionText) { 2113 throw new Error('ACTIVATION_TASK_ID_CONFLICT'); 2114 } 2115 return { pageId: existing.page.id, taskId: existing.block.id }; 2116 } 2117 2118 let page = pageId ? store.getPage(pageId) : null; 2119 if (!page) { 2120 page = store.getPages().find((candidate) => ( 2121 String(candidate?._activationDraftTaskId || '') === String(taskId) 2122 )) || null; 2123 } 2124 if (!page) { 2125 const reusableEmptyPage = store.getPages().find((candidate) => ( 2126 !candidate.isNotion 2127 && candidate.source !== 'notion' 2128 && String(candidate.title || candidate.name || '') === i18n.t('project_activation_default_project') 2129 && store.getBlocks(candidate.id).length === 0 2130 )); 2131 page = reusableEmptyPage || store.createPage(i18n.t('project_activation_default_project'), null); 2132 } 2133 2134 if (String(page._activationDraftTaskId || '') !== String(taskId)) { 2135 page._activationDraftTaskId = taskId; 2136 store.save(); 2137 } 2138 const task = store.addBlock(page.id, { id: taskId, type: 'todo', text: actionText }); 2139 return { pageId: page.id, taskId: task.id }; 2140 } 2141 2142 function hasResumableActivationDraftClaim(ownerId, actionText) { 2143 const claim = readActivationDraftConsumption(window.sessionStorage); 2144 return Boolean( 2145 claim 2146 && claim.ownerId === String(ownerId || '') 2147 && claim.actionText === actionText 2148 ); 2149 } 2150 2151 async function consumeAuthenticatedActivationDraft(ownerId = ctx.currentUser?.id) { 2152 const normalizedOwnerId = String(ownerId || '').trim(); 2153 const actionText = readActivationDraft(window.sessionStorage); 2154 if (!normalizedOwnerId || !actionText) return { status: 'no-draft' }; 2155 2156 const manualPages = store.getPages().filter((page) => !page.isNotion && page.source !== 'notion'); 2157 if (manualPages.length > 0 && !hasResumableActivationDraftClaim(normalizedOwnerId, actionText)) { 2158 return { status: 'existing-account' }; 2159 } 2160 2161 activationDraftConsumptionPending = true; 2162 const activationForm = container.querySelector('#project-activation-form'); 2163 activationForm?.setAttribute('aria-busy', 'true'); 2164 activationForm?.querySelectorAll('input, button').forEach((element) => { 2165 element.disabled = true; 2166 }); 2167 2168 try { 2169 return await consumeActivationDraft({ 2170 storage: window.sessionStorage, 2171 ownerId: normalizedOwnerId, 2172 ensureAction: ensureActivationDraftAction, 2173 addToToday: ({ taskId }) => ( 2174 isBlockInToday({ id: taskId }) 2175 ? { status: 'already-added' } 2176 : addBlockToToday(taskId) 2177 ), 2178 openFocus: ({ actionText: savedAction, pageId, taskId }) => { 2179 setSelectedPageId(pageId); 2180 selectedPageIds.clear(); 2181 selectedPageIds.add(pageId); 2182 firstFocusActivationTaskId = null; 2183 projectActivationDraft.title = ''; 2184 projectActivationDraft.helpExpanded = false;
2185 projectActivationDraft.selectedExampleIndex = null; 2186 render(); 2187 ctx.bus?.emit('app:push-task', { 2188 blockId: taskId, 2189 blockText: savedAction 2190 }); 2191 } 2192 }); 2193 } catch (error) { 2194 const claim = readActivationDraftConsumption(window.sessionStorage); 2195 firstFocusActivationTaskId = claim?.taskId || firstFocusActivationTaskId; 2196 console.warn('Impossible de terminer automatiquement la première action après inscription.', error); 2197 render(); 2198 return { status: 'incomplete', error }; 2199 } finally { 2200 activationDraftConsumptionPending = false; 2201 } 2202 } 2203 2204 function captureFocusedBlockCaret() { 2205 const editor = container.querySelector('.editable-text:focus'); 2206 if (!editor?.dataset.id) return null; 2207 2208 const selection = window.getSelection(); 2209 let offset = editor.innerText.length; 2210 if (selection?.rangeCount) { 2211 const range = selection.getRangeAt(0); 2212 if (editor.contains(range.endContainer)) { 2213 const beforeCaret = range.cloneRange(); 2214 beforeCaret.selectNodeContents(editor); 2215 beforeCaret.setEnd(range.endContainer, range.endOffset); 2216 offset = beforeCaret.toString().length; 2217 } 2218 } 2219 2220 return { blockId: editor.dataset.id, offset }; 2221 } 2222 2223 function restoreFocusedBlockCaret(savedCaret) { 2224 if (!savedCaret) return; 2225 focusBlock(savedCaret.blockId, savedCaret.offset); 2226 } 2227 2228 // -- RENDER FUNCTION -- 2229 function render(options) { 2230 const savedCaret = options?.preserveCaret === false 2231 ? null 2232 : captureFocusedBlockCaret(); 2233 const savedScrolls = saveProjectScrollState(); 2234 closeTaskPriorityMenu(false); 2235 selectedPageId = localStorage.getItem('roxima_selected_project_page_id') || null; 2236 if (selectionPageId !== selectedPageId) { 2237 selection.ids.clear(); 2238 selection.anchorBlockId = null; 2239 selection.extentBlockId = null; 2240 selectionPageId = selectedPageId; 2241 } 2242 const activeWorkspaceId = getActiveNotionWorkspaceId(); 2243 const pages = store.getPages().filter(p => { 2244 if ((p.source === 'manual' || !p.source) && !p.isNotion) return true; 2245 if ((p.source === 'notion' || p.isNotion) && p.notionWorkspaceId === activeWorkspaceId) return true; 2246 return false; 2247 }); 2248 2249 // Resolve orphans: if page parentId is not in the filtered pages list, treat parentId as null 2250 const pageIds = new Set(pages.map(p => p.id)); 2251 const resolvedPages = pages.map(p => { 2252 if (p.parentId && !pageIds.has(p.parentId)) { 2253 return { ...p, parentId: null }; 2254 } 2255 return p; 2256 }); 2257 2258 if (!selectedPageId && resolvedPages.length > 0) { 2259 setSelectedPageId(resolvedPages[0].id); 2260 selectedPageIds.add(selectedPageId); 2261 } else if (selectedPageId && !pageIds.has(selectedPageId)) { 2262 setSelectedPageId(resolvedPages.length > 0 ? resolvedPages[0].id : null); 2263 if (selectedPageId) selectedPageIds.add(selectedPageId); 2264 } 2265 2266 if (selectedPageId) { 2267 expandedPages.add(selectedPageId); 2268 let curr = store.getPage(selectedPageId); 2269 while (curr && curr.parentId) { 2270 expandedPages.add(curr.parentId); 2271 curr = store.getPage(curr.parentId); 2272 } 2273 } 2274 2275 const currentPage = selectedPageId ? store.getPage(selectedPageId) : null; 2276 const blocks = currentPage ? store.getBlocks(currentPage.id) : []; 2277 const activationStep = getProjectActivationStep({ 2278 projectCount: resolvedPages.length, 2279 hasCurrentPage: Boolean(currentPage), 2280 blockCount: blocks.length 2281 }); 2282 if (activationStep === PROJECT_ACTIVATION_STEP.CREATE_PROJECT && !projectActivationDraft.title) { 2283 projectActivationDraft.title = readActivationDraft(window.sessionStorage); 2284 } 2285 2286 // Determine Layout Classes 2287 let layoutClasses = 'obj-layout'; 2288 if (sidebarForcedState === 'open') layoutClasses += ' show-sidebar'; 2289 if (sidebarForcedState === 'closed') layoutClasses += ' hide-sidebar'; 2290 const isSidebarOpen = sidebarForcedState === 'open' || (sidebarForcedState !== 'closed' && Boolean(container.closest('.card-expanded'))); 2291 2292 const activeWorkspace = getActiveNotionWorkspace(); 2293 const isNotionConnected = Boolean(activeWorkspace && !isDemoNotionWorkspace(activeWorkspace)); 2294 const notionBtnTitle = isNotionConnected 2295 ? translateProject('projects_notion_connected', { workspace: activeWorkspace.name }) 2296 : i18n.t('projects_notion_disconnected'); 2297 2298 // Main Template 2299 container.innerHTML = ` 2300 <div class="card" style="height: 100%; display: flex; flex-direction: column; position: relative;"> 2301 <div class="card-header" style="display: flex; align-items: center; justify-content: space-between; gap: 12px;"> 2302 <div style="display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;"> 2303 <button class="btn-toggle-sidebar" id="bt
2303n-toggle-sidebar" type="button" data-i18n-title="projects_menu" data-i18n-aria-label="projects_menu" title="${escapeAttribute(i18n.t('projects_menu'))}" aria-label="${escapeAttribute(i18n.t('projects_menu'))}" style="margin: 0; flex-shrink: 0;"> 2304 ${isSidebarOpen 2305 ? `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false"><polyline points="15 18 9 12 15 6"></polyline></svg>` 2306 : `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>` 2307 } 2308 </button> 2309 <h2 class="card-title" data-i18n="projects_title" style="margin: 0; flex-shrink: 0;">Projects</h2> 2310 ${currentPage ? ` 2311 <div class="project-header-inline" style="display: flex; align-items: center; gap: 6px; flex: 1; min-width: 0; margin-left: 6px; padding-left: 4px;"> 2312 <button id="header-icon-btn" type="button" data-i18n-title="projects_change_icon" data-i18n-aria-label="projects_change_icon" title="${escapeAttribute(i18n.t('projects_change_icon'))}" aria-label="${escapeAttribute(i18n.t('projects_change_icon'))}" style="font-size:1.4rem; background:none; border:none; cursor:pointer; display:flex; align-items:center; justify-content:center; border-radius:4px; width:30px; height:30px; flex-shrink:0;"> 2313 ${escapeHtml(currentPage.icon || 'ð')} 2314 </button> 2315 <input type="text" class="page-title-editor" value="${escapeAttribute(currentPage.title)}" data-id="${escapeAttribute(currentPage.id)}" placeholder="Sans titre" data-i18n-placeholder="common_untitled" style="flex: 1; min-width: 0; font-size: 1.15rem; font-weight: 700; color: var(--text-main);"> 2316 ${currentPage.isNotion ? `<span class="notion-header-badge" style="background: #000; padding: 4px 6px; border-radius: 4px; flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center;" title="${escapeAttribute(i18n.t('projects_notion_page'))}"><img src="assets/img/Logo_Notion.png" width="12" height="12" style="filter: invert(1);"></span>` : ''} 2317 </div> 2318 ` : ''} 2319 </div> 2320 <button class="icon-btn project-mobile-actions-toggle" id="btn-project-actions-mobile" type="button" 2321 aria-expanded="false" aria-controls="project-header-actions" 2322 data-i18n-title="common_actions" data-i18n-aria-label="common_actions" 2323 title="${escapeAttribute(i18n.t('common_actions'))}" aria-label="${escapeAttribute(i18n.t('common_actions'))}"> 2324 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true" focusable="false"> 2325 <circle cx="12" cy="5" r="1"></circle><circle cx="12" cy="12" r="1"></circle><circle cx="12" cy="19" r="1"></circle> 2326 </svg> 2327 </button> 2328 <div class="card-actions project-header-actions" id="project-header-actions"> 2329 <button class="icon-btn" id="btn-sync-notion" type="button" title="${escapeAttribute(notionBtnTitle)}" aria-label="${escapeAttribute(notionBtnTitle)}" style="position: relative; display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; padding: 0;"> 2330 <img src="assets/img/Logo_Notion.png" width="16" height="16" alt="Notion" style="filter: drop-shadow(0 1px 1px rgba(0,0,0,0.15));"> 2331 <span class="notion-status-dot" style="position: absolute; top: 3px; right: 3px; width: 7px; height: 7px; border-radius: 50%; background: ${isNotionConnected ? '#22c55e' : '#ef4444'}; box-shadow: 0 0 5px ${isNotionConnected ? 'rgba(34, 197, 94, 0.7)' : 'rgba(239, 68, 68, 0.7)'}; border: 1.5px solid var(--bg-card, #fff);"></span> 2332 </button> 2333 ${currentPage ? ` 2334 <button class="icon-btn btn-ai-planner" id="bt
2334n-ai-planner" type="button" data-i18n-title="projects_ai_planner" data-i18n-aria-label="projects_ai_planner" title="${escapeAttribute(i18n.t('projects_ai_planner'))}" aria-label="${escapeAttribute(i18n.t('projects_ai_planner'))}"> 2335 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-sparkles"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/><path d="m5 3 1 2.5L8.5 6 6 7 5 9.5 4 7 1.5 6 4 5.5z"/><path d="m19 17 1 2.5 2.5.5-2.5 1-1 2.5-1-2.5-2.5-1 2.5-1z"/></svg> 2336 </button> 2337 ` : ''} 2338 <button class="icon-btn" id="btn-paste-tasks" type="button" data-i18n-title="projects_paste_tasks" data-i18n-aria-label="projects_paste_tasks" title="${escapeAttribute(i18n.t('projects_paste_tasks'))}" aria-label="${escapeAttribute(i18n.t('projects_paste_tasks'))}"> 2339 <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg> 2340 </button> 2341 <button class="icon-btn" id="btn-zoom" type="button" data-i18n-title="projects_expand" data-i18n-aria-label="projects_expand" title="${escapeAttribute(i18n.t('projects_expand'))}" aria-label="${escapeAttribute(i18n.t('projects_expand'))}"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" focusable="false"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" y1="3" x2="14" y2="10"></line><line x1="3" y1="21" x2="10" y2="14"></line></svg></button> 2342 </div> 2343 </div> 2344 ${renderPlanningSessionPanel()} 2345 2346 <div class="${layoutClasses}"> 2347 <!-- SIDEBAR --> 2348 <div class="obj-sidebar"> 2349 <div class="obj-actions"> 2350 <button class="obj-btn-add" id="btn-add-page"> 2351 <span style="font-size:1.2em; line-height:1;">+</span> <span data-i18n="common_project">Project</span> 2352 </button> 2353 </div> 2354 <div class="obj-tree" id="page-tree"></div> 2355 </div> 2356 2357 <!-- CONTENT PAGE / EDITOR --> 2358 <div class="obj-content" id="editor-scroller"> 2359 ${currentPage ? ` 2360 <div class="editor-container" id="editor-blocks" role="listbox" aria-multiselectable="true" aria-label="${escapeAttribute(i18n.t('projects_blocks_aria'))}"> 2361 ${activationStep === PROJECT_ACTIVATION_STEP.ADD_FIRST_TASK 2362 ? renderFirstTaskActivation() 2363 : renderBlocks(blocks)} 2364 </div> 2365 2366 ` : renderProjectCreationActivation(projectActivationDraft)} 2367 </div> 2368 </div> 2369 ${renderDailyPrioritiesPanel()} 2370 <!-- Floating Selection Action Bar --> 2371 <div class="selection-action-bar" id="project-selection-bar"> 2372 <span class="selection-count">0 ${i18n.t('selection_items_selected')}</span> 2373 <button class="selection-btn btn-copy" type="button"> 2374 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>
2375 <span data-i18n="common_copy">${i18n.t('common_copy')}</span> 2376 </button> 2377 <button class="selection-btn btn-paste" type="button" disabled> 2378 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"></path><rect x="9" y="3" width="6" height="4" rx="1"></rect></svg> 2379 <span data-i18n="common_paste">${i18n.t('common_paste')}</span> 2380 </button> 2381 <button class="selection-btn btn-duplicate" type="button"> 2382 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="8" y="8" width="12" height="12" rx="2"></rect><path d="M16 8V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h2"></path></svg> 2383 <span data-i18n="common_duplicate">${i18n.t('common_duplicate')}</span> 2384 </button> 2385 <button class="selection-btn danger-btn btn-delete" type="button"> 2386 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path><line x1="10" y1="11" x2="10" y2="17"></line><line x1="14" y1="11" x2="14" y2="17"></line></svg> 2387 <span data-i18n="common_delete">${i18n.t('common_delete')}</span> 2388 </button> 2389 <button class="selection-btn cancel-btn btn-cancel" type="button" data-i18n-title="common_close" data-i18n-aria-label="common_close" title="${escapeAttribute(i18n.t('common_close'))}" aria-label="${escapeAttribute(i18n.t('common_close'))}"> 2390 <span aria-hidden="true">×</span> 2391 </button> 2392 </div> 2393 </div> 2394 `; 2395 2396 container.querySelector('[data-planning-session-finish]')?.addEventListener('click', () => { 2397 completePlanningSession('confirmed'); 2398 }); 2399 updatePlanningSessionUi(); 2400 2401 const activationForm = container.querySelector('#project-activation-form'); 2402 if (activationForm) { 2403 const helpToggle = container.querySelector('#project-activation-help-toggle'); 2404 const helpPanel = container.querySelector('#project-activation-help'); 2405 const titleInput = activationForm.querySelector('#project-activation-title'); 2406 2407 helpToggle?.addEventListener('click', () => { 2408 const expanded = helpToggle.getAttribute('aria-expanded') === 'true'; 2409 projectActivationDraft.helpExpanded = !expanded; 2410 helpToggle.setAttribute('aria-expanded', String(!expanded)); 2411 if (helpPanel) helpPanel.hidden = expanded; 2412 }); 2413 2414 titleInput?.addEventListener('input', () => { 2415 projectActivationDraft.title = titleInput.value; 2416 const selectedButton = projectActivationDraft.selectedExampleIndex
2417 ? container.querySelector(`[data-project-example-index="${projectActivationDraft.selectedExampleIndex}"]`) 2418 : null; 2419 if (selectedButton?.dataset.projectExampleTitle !== titleInput.value) { 2420 projectActivationDraft.selectedExampleIndex = null; 2421 container.querySelectorAll('[data-project-example-title]').forEach((button) => { 2422 button.classList.remove('is-selected'); 2423 button.setAttribute('aria-pressed', 'false'); 2424 }); 2425 } 2426 }); 2427 2428 container.querySelectorAll('[data-project-example-title]').forEach((exampleButton) => { 2429 exampleButton.addEventListener('click', () => { 2430 const exampleTitle = String(exampleButton.dataset.projectExampleTitle || '').trim(); 2431 if (!exampleTitle || !titleInput) return; 2432 2433 projectActivationDraft.title = exampleTitle; 2434 projectActivationDraft.selectedExampleIndex = Number(exampleButton.dataset.projectExampleIndex) || null; 2435 titleInput.value = exampleTitle; 2436 titleInput.dispatchEvent(new Event('input', { bubbles: true })); 2437 container.querySelectorAll('[data-project-example-title]').forEach((button) => { 2438 const selected = button === exampleButton; 2439 button.classList.toggle('is-selected', selected); 2440 button.setAttribute('aria-pressed', String(selected)); 2441 }); 2442 titleInput.focus(); 2443 }); 2444 }); 2445 2446 activationForm.addEventListener('submit', async (event) => { 2447 event.preventDefault(); 2448 if (!checkAuth()) return; 2449 2450 if (readActivationDraft(window.sessionStorage) && ctx.currentUser?.id) { 2451 await consumeAuthenticatedActivationDraft(ctx.currentUser.id); 2452 return; 2453 } 2454 if (activationDraftConsumptionPending) return; 2455 2456 const actionText = String(titleInput?.value || '').trim(); 2457 if (!actionText) { 2458 titleInput?.focus(); 2459 return; 2460 } 2461 2462 const submitButton = activationForm.querySelector('button[type="submit"]'); 2463 if (submitButton?.disabled) return; 2464 if (submitButton) submitButton.disabled = true; 2465 2466 const newPage = store.createPage(i18n.t('project_activation_default_project'), null); 2467 const firstTask = store.addBlock(newPage.id, { type: 'todo', text: actionText }); 2468 clearActivationDraft(window.sessionStorage); 2469 firstFocusActivationTaskId = firstTask.id; 2470 projectActivationDraft.title = ''; 2471 projectActivationDraft.helpExpanded = false; 2472 projectActivationDraft.selectedExampleIndex = null; 2473 setSelectedPageId(newPage.id); 2474 selectedPageIds.clear(); 2475 selectedPageIds.add(newPage.id); 2476 2477 const addedToToday = await addBlockToToday(firstTask.id); 2478 if (addedToToday) { 2479 firstFocusActivationTaskId = null; 2480 render(); 2481 window.setTimeout(() => { 2482 ctx.bus?.emit('app:push-task', { 2483 blockId: firstTask.id, 2484 blockText: actionText 2485 }); 2486 }, 0); 2487 return; 2488 } 2489 2490 // The action is already saved even if Today cannot be updated. 2491 // Keep the existing recovery CTA visible instead of losing work. 2492 render(); 2493 window.setTimeout(() => { 2494 const taskInput = Array.from(container.querySelectorAll('.editable-text')) 2495 .find(input => input.dataset.id === firstTask.id); 2496 taskInput?.focus(); 2497 }, 50); 2498 }); 2499 } 2500 2501 renderTree(container.querySelector('#page-tree'), resolvedPages, null); 2502 2503 // Ensure global menus exist on body 2504 if (!document.getElementById('slash-menu')) { 2505 const sm = document.createElement('div'); 2506 sm.id = 'slash-menu'; 2507 sm.style.display = 'none'; 2508 document.body.appendChild(sm); 2509 } 2510 if (!document.getElementById('block-plus-menu')) { 2511 const bpm = document.createElement('div'); 2512 bpm.id = 'block-plus-menu'; 2513 bpm.style.display = 'none'; 2514 document.body.appendChild(bpm); 2515 } 2516 2517 if (currentPage) { 2518 initEditor(container.querySelector('#editor-blocks'), currentPage.id, ctx.bus); 2519 initSlashMenu(document.getElementById('slash-menu')); 2520 initPlusMenu(document.getElementById('block-plus-menu')); 2521 2522 // Listeners for inputs are kept here because elements are recreated 2523 const titleInput = container.querySelector('.page-title-editor'); 2524 if (titleInput) { 2525 titleInput.addEventListener('input', (e) => store.renamePage(e.target.dataset.id, e.target.value)); 2526 titleInput.addEventListener('blur', render); 2527 } 2528 2529 // Bind Icon Picker 2530 const iconBtn = container.querySelector('#header-icon-btn'); 2531 if (iconBtn) { 2532 iconBtn.addEventListener('click', (e) => { 2533 const rect = iconBtn.getBoundingClientRect(); 2534 showEmojiPicker(rect.left, rect.bottom + 5, (icon) => { 2535 store.setPageIcon(currentPage.id, icon); 2536 render(); 2537 }); 2538 }); 2539 } 2540 2541 // Bind AI Planner 2542 const btnAiPlanner = container.querySelector('#btn-ai-planner'); 2543 if (btnAiPlanner) { 2544 btnAiPlanner.addEventListener('click', () => { 2545 openAiPlanner(currentPage.id, ctx, render); 2546 }); 2547 } 2548 2549 // Bind Page Links 2550 container.querySelectorAll('.page-link-block').forEach(el => { 2551 el.addEventListener('click', (e) => { 2552 setSelectedPageId(e.currentTarget.dataset.pageId); 2553 render(); 2554 }); 2555 }); 2556 } 2557 2558 const btnSyncNotion = container.querySelector('#btn-sync-notion'); 2559 if (btnSyncNotion) { 2560 btnSyncNotion.addEventListener('click', () => { 2561 showNotionConfigModal(); 2562 }); 2563 } 2564 2565 container.querySelector('#btn-paste-tasks')?.addEventListener('click', () => { 2566 ctx.bus?.emit('todo:open-import'); 2567 }); 2568 2569 const mobileProjectActionsToggle = container.querySelector('#btn-project-actions-mobile'); 2570 const projectHeaderActions = container.querySelector('#project-header-actions'); 2571 const closeMobileProjectActions = () => { 2572 projectHeaderActions?.classList.remove('is-mobile-open'); 2573 mobileProjectActionsToggle?.setAttribute('aria-expanded', 'false'); 2574 }; 2575 2576 mobileProjectActionsToggle?.addEventListener('click', (event) => { 2577 event.stopPropagation(); 2578 const willOpen = !projectHeaderActions?.classList.contains('is-mobile-open'); 2579 projectHeaderActions?.classList.toggle('is-mobile-open', willOpen); 2580 mobileProjectActionsToggle.setAttribute('aria-expanded', String(willOpen)); 2581 }); 2582 projectHeaderActions?.addEventListener('click', (event) => { 2583 event.stopPropagation(); 2584 if (event.target.closest('button')) closeMobileProjectActions(); 2585 }); 2586 container.querySelector('.obj-layout')?.addEventListener('click', closeMobileProjectActions); 2587 container.querySelector('.daily-priorities-panel')?.addEventListener('click', closeMobileProjectActions); 2588 2589 bindDailyPriorityUI(); 2590 2591 bindGlobalEvents(container, store, container.querySelector('#page-tree'), ctx.bus); 2592 2593 // Init Drag & Drop for Editor 2594 // Init Drag & Drop for Editor 2595 initDragDropSystem(container.querySelector('#editor-blocks'), store); 2596 2597 // Restore selection visuals after render 2598 selection.updateVisuals(); 2599 2600 // Restore scroll position 2601 restoreProjectScrollState(savedScrolls); 2602 requestAnimationFrame(() => restoreProjectScrollState(savedScrolls)); 2603 restoreFocusedBlockCaret(savedCaret); 2604 } 2605 2606 // SETUP BUS LISTENER (Once per mount logic, but render might be recalled?)
2607 // Actually render is internal. 2608 // We should attach the bus listener ONCE in mount, not in render. 2609 // But render is defined in mount. 2610 // So we add it here (inside mount, after render def). 2611 if (ctx.bus) { 2612 if (container.__roximaProjectsBusHandlers) { 2613 ctx.bus.off('objectifs:refresh', container.__roximaProjectsBusHandlers.objectifsRefresh); 2614 ctx.bus.off('i18n:changed', container.__roximaProjectsBusHandlers.i18nChanged); 2615 ctx.bus.off('daily-priorities:changed', container.__roximaProjectsBusHandlers.dailyPrioritiesChanged); 2616 ctx.bus.off('daily-priority:open-source', container.__roximaProjectsBusHandlers.dailyPriorityOpenSource); 2617 ctx.bus.off('daily-priority:complete-request', container.__roximaProjectsBusHandlers.dailyPriorityCompleteRequest); 2618 ctx.bus.off('daily-priority:block-request', container.__roximaProjectsBusHandlers.dailyPriorityBlockRequest); 2619 ctx.bus.off('calendar:changed', container.__roximaProjectsBusHandlers.calendarChanged); 2620 ctx.bus.off('planning-session:start', container.__roximaProjectsBusHandlers.planningSessionStart); 2621 ctx.bus.off('auth:state', container.__roximaProjectsBusHandlers.authState); 2622 ctx.bus.off('sync:account-hydrated', container.__roximaProjectsBusHandlers.accountHydrated); 2623 } 2624 2625 const deferRenderWhileActivationIsEdited = () => { 2626 const activationInput = container.querySelector('#project-activation-title:focus'); 2627 if (!activationInput) return false; 2628 if (!pendingActivationRender) { 2629 pendingActivationRender = true; 2630 activationInput.addEventListener('blur', () => { 2631 pendingActivationRender = false;
2632 if (container.closest('body')) render(); 2633 }, { once: true }); 2634 } 2635 return true; 2636 }; 2637 const objectifsRefresh = () => { 2638 if (!container.closest('body') || deferRenderWhileActivationIsEdited()) return; 2639 render(); 2640 }; 2641 const i18nChanged = () => { 2642 if (!container.closest('body') || deferRenderWhileActivationIsEdited()) return; 2643 render(); 2644 }; 2645 const dailyPrioritiesChanged = () => { 2646 if (!container.closest('body')) return; 2647 const activeEditor = container.querySelector('.editable-text:focus, .page-title-editor:focus'); 2648 if (activeEditor) { 2649 if (!pendingDailyRender) { 2650 pendingDailyRender = true; 2651 activeEditor.addEventListener('blur', () => { 2652 pendingDailyRender = false; 2653 // Let the editor's 200 ms local-save debounce commit 2654 // the final keystroke before rebuilding the DOM. 2655 window.setTimeout(() => { 2656 if (container.closest('body')) render(); 2657 }, 225); 2658 }, { once: true }); 2659 } 2660 return; 2661 } 2662 render(); 2663 }; 2664 const dailyPriorityOpenSource = payload => openDailyPrioritySource(payload); 2665 const dailyPriorityCompleteRequest = payload => handleDailyTaskExecutionRequest(payload, 'done'); 2666 const dailyPriorityBlockRequest = payload => handleDailyTaskExecutionRequest(payload, 'blocked'); 2667 const calendarChanged = () => { 2668 if (!container.closest('body') || deferRenderWhileActivationIsEdited()) return; 2669 render(); 2670 }; 2671 const planningSessionStart = payload => startPlanningSession(payload); 2672 const authState = ({ user } = {}) => setPlanningOwner(user); 2673 const accountHydrated = ({ userId } = {}) => { 2674 const activeOwnerId = String(ctx.currentUser?.id || ''); 2675 if (!activeOwnerId || String(userId || '') !== activeOwnerId) return; 2676 void consumeAuthenticatedActivationDraft(activeOwnerId); 2677 }; 2678 2679 container.__roximaProjectsBusHandlers = { 2680 objectifsRefresh, 2681 i18nChanged, 2682 dailyPrioritiesChanged, 2683 dailyPriorityOpenSource, 2684 dailyPriorityCompleteRequest, 2685 dailyPriorityBlockRequest, 2686 calendarChanged, 2687 planningSessionStart, 2688 authState, 2689 accountHydrated 2690 }; 2691 2692 ctx.bus.on('objectifs:refresh', objectifsRefresh); 2693 ctx.bus.on('i18n:changed', i18nChanged); 2694 ctx.bus.on('daily-priorities:changed', dailyPrioritiesChanged); 2695 ctx.bus.on('daily-priority:open-source', dailyPriorityOpenSource); 2696 ctx.bus.on('daily-priority:complete-request', dailyPriorityCompleteRequest); 2697 ctx.bus.on('daily-priority:block-request', dailyPriorityBlockRequest); 2698 ctx.bus.on('calendar:changed', calendarChanged); 2699 ctx.bus.on('planning-session:start', planningSessionStart); 2700 ctx.bus.on('auth:state', authState); 2701 ctx.bus.on('sync:account-hydrated', accountHydrated); 2702 } 2703 2704 // --- SELECTION MANAGER --- 2705 const selection = { 2706 ids: new Set(), 2707 anchorBlockId: null, 2708 extentBlockId: null, 2709 2710 clear() { 2711 this.ids.clear(); 2712 this.anchorBlockId = null; 2713 this.extentBlockId = null; 2714 this.updateVisuals(); 2715 }, 2716 2717 replace(ids, { anchorId = null, extentId = null } = {}) { 2718 this.ids = new Set(Array.from(ids || [], id => String(id))); 2719 this.anchorBlockId = anchorId ? String(anchorId) : (this.ids.values().next().value || null); 2720 this.extentBlockId = extentId ? String(extentId) : this.anchorBlockId; 2721 this.updateVisuals(); 2722 }, 2723 2724 add(id, { preserveAnchor = false } = {}) { 2725 const strId = String(id); 2726 this.ids.add(strId); 2727 if (!preserveAnchor || !this.anchorBlockId) this.anchorBlockId = strId; 2728 this.extentBlockId = strId; 2729 this.updateVisuals(); 2730 }, 2731 2732 toggle(id, { preserveAnchor = false } = {}) { 2733 const strId = String(id); 2734 if (this.ids.has(strId)) { 2735 this.ids.delete(strId); 2736 if (this.anchorBlockId === strId) this.anchorBlockId = this.ids.values().next().value || null; 2737 } else { 2738 this.ids.add(strId); 2739 if (!preserveAnchor || !this.anchorBlockId) this.anchorBlockId = strId; 2740 } 2741 this.extentBlockId = strId; 2742 this.updateVisuals(); 2743 }, 2744 2745 setSingle(id) { 2746 const strId = String(id); 2747 this.replace([strId], { anchorId: strId, extentId: strId }); 2748 }, 2749 2750 setRange(targetId, allBlockIds, { additive = false } = {}) { 2751 const orderedIds = allBlockIds.map(id => String(id)); 2752 if (!this.anchorBlockId || !orderedIds.includes(this.anchorBlockId)) { 2753 this.setSingle(targetId); 2754 return; 2755 } 2756 const nextIds = getRangeSelection( 2757 orderedIds, 2758 this.anchorBlockId, 2759 targetId, 2760 additive ? [...this.ids] : [] 2761 ); 2762 this.replace(nextIds, { anchorId: this.anchorBlockId, extentId: targetId }); 2763 }, 2764 2765 updateVisuals() { 2766 const allBlocks = container.querySelectorAll('.editor-block'); 2767 allBlocks.forEach(el => { 2768 const selected = this.ids.has(el.dataset.id); 2769 el.classList.toggle('selected', selected); 2770 el.setAttribute('aria-selected', String(selected)); 2771 }); 2772 2773 // Update selection state in topbar capsule 2774 window.Roxima?.setSelectionState?.({ 2775 count: this.ids.size, 2776 canPaste: projectBlockClipboard.length > 0, 2777 onCopy: () => copyCurrentBlockSelection(), 2778 onPaste: () => insertProjectClipboard(), 2779 onDuplicate: () => insertProjectClipboard({ duplicate: true }), 2780 onDelete: () => deleteCurrentBlockSelection(), 2781 onClear: () => selection.clear() 2782 }); 2783 2784 const bar = container.querySelector('#project-selection-bar'); 2785 if (bar) { 2786 if (this.ids.size > 0) { 2787 const countEl = bar.querySelector('.selection-count'); 2788 if (countEl) countEl.textContent = `${this.ids.size} ${i18n.t('selection_items_selected')}`; 2789 bar.classList.add('visible'); 2790 } else { 2791 bar.classList.remove('visible'); 2792 } 2793 const pasteButton = bar.querySelector('.btn-paste'); 2794 if (pasteButton) pasteButton.disabled = projectBlockClipboard.length === 0; 2795 } 2796 }, 2797 2798 getSortedIds(allBlockIds) { 2799 // Return IDs in order of appearance 2800 return allBlockIds.filter(id => this.ids.has(id)); 2801 } 2802 }; 2803 2804 const getEditorBlockIds = (editorEl = container) => Array.from( 2805 editorEl.querySelectorAll('.editor-block'), 2806 el => el.dataset.id 2807 ); 2808 2809 function getSelectedBlocks({ includeDescendants = true } = {}) { 2810 if (!selectedPageId) return []; 2811 const blocks = store.getBlocks(selectedPageId); 2812 if (includeDescendants) return getSelectionWithDescendants(blocks, selection.ids); 2813 const selectedIds = new Set(selection.ids); 2814 return blocks.filter(block => selectedIds.has(String(block.id))); 2815 } 2816 2817 function copyCurrentBlockSelection({ showToast = true } = {}) { 2818 if (!selectedPageId || selection.ids.size === 0) return false;
2819 const blocks = store.getBlocks(selectedPageId); 2820 projectBlockClipboard = copySelectedBlocks(blocks, selection.ids); 2821 const plainText = formatSelectedBlocksAsText(blocks, selection.ids); 2822 if (!projectBlockClipboard.length) return false; 2823 2824 navigator.clipboard?.writeText(plainText).catch(() => {}); 2825 selection.updateVisuals(); 2826 if (showToast) window.Roxima.showToast?.(i18n.t('projects_toast_copied')); 2827 return true; 2828 } 2829 2830 function insertProjectClipboard({ duplicate = false } = {}) { 2831 if (!selectedPageId || selection.ids.size === 0) return []; 2832 const sourceBlocks = duplicate 2833 ? copySelectedBlocks(store.getBlocks(selectedPageId), selection.ids) 2834 : projectBlockClipboard; 2835 if (!sourceBlocks.length) return []; 2836 2837 const selectedBlocks = getSelectedBlocks(); 2838 const afterBlockId = selectedBlocks.at(-1)?.id || null; 2839 const insertedBlocks = store.insertBlocks(selectedPageId, sourceBlocks, afterBlockId); 2840 if (!insertedBlocks.length) return []; 2841 2842 const insertedIds = insertedBlocks.map(block => String(block.id)); 2843 selection.replace(insertedIds, { anchorId: insertedIds[0], extentId: insertedIds.at(-1) }); 2844 render(); 2845 window.Roxima.showToast?.(i18n.t(duplicate ? 'projects_toast_duplicated' : 'projects_toast_pasted')); 2846 return insertedBlocks; 2847 } 2848 2849 function deleteCurrentBlockSelection() { 2850 if (!selectedPageId || selection.ids.size === 0) return []; 2851 const pageId = selectedPageId; 2852 const blocksToDelete = getSelectedBlocks(); 2853 blocksToDelete.forEach(block => { 2854 if (block.isNotion && block.notionBlockId) deleteBlockFromNotion(pageId, block.notionBlockId); 2855 }); 2856 const deletedBlocks = store.deleteBlocks(pageId, blocksToDelete.map(block => block.id)); 2857 selection.clear(); 2858 if (store.getBlocks(pageId).length === 0) store.addBlock(pageId, { type: 'p', text: '' }); 2859 render(); 2860 return deletedBlocks; 2861 } 2862 2863 function moveCurrentBlockSelection(direction) { 2864 if (!selectedPageId || selection.ids.size === 0) return []; 2865 const blocks = store.getBlocks(selectedPageId); 2866 const movingBlocks = getSelectionWithDescendants(blocks, selection.ids); 2867 if (!movingBlocks.length) return []; 2868 2869 const movingIds = new Set(movingBlocks.map(block => String(block.id))); 2870 const firstIndex = blocks.findIndex(block => movingIds.has(String(block.id))); 2871 let lastIndex = -1; 2872 blocks.forEach((block, index) => { 2873 if (movingIds.has(String(block.id))) lastIndex = index; 2874 }); 2875 2876 const targetIndex = direction === 'up' ? firstIndex - 1 : lastIndex + 1; 2877 if (targetIndex < 0 || targetIndex >= blocks.length) return []; 2878 2879 const targetBlock = blocks[targetIndex]; 2880 const movedIds = store.moveBlocks( 2881 selectedPageId, 2882 selection.getSortedIds(getEditorBlockIds()), 2883 targetBlock.id, 2884 direction === 'up' ? 'before' : 'after' 2885 ); 2886 if (movedIds.length) render(); 2887 return movedIds; 2888 } 2889 2890 // --- DRAG & DROP & SELECTION SYSTEM --- 2891 function initDragDropSystemLegacy(editorEl, store) { 2892 if (!editorEl) return; 2893 2894 let dragAllowed = false; 2895 let dragSrcId = null; 2896 let isDragOp = false; 2897 2898 let isSelecting = false; 2899 let startY = 0; 2900 let startX = 0; 2901 let longPressTimer = null; 2902 let dragSelectionActive = false; 2903 let startBlockId = null; 2904 let initialSelectedIds = new Set(); 2905 2906 let isPointerDown = false; 2907 let startEditable = null; 2908 2909 editorEl.addEventListener('pointerdown', (e) => { 2910 if (e.button !== 0 && e.pointerType === 'mouse') return; 2911 2912 // Ignore interactive elements 2913 const isInteractive = e.target.closest('.block-controls button, .action-add-sub, .action-del, input, button'); 2914 if (isInteractive) return; 2915 2916 isPointerDown = true; 2917 startX = e.clientX; 2918 startY = e.clientY; 2919 dragSelectionActive = false;
2920 startEditable = e.target.closest('.editable-text'); 2921 2922 initialSelectedIds = new Set(selection.ids); 2923 2924 const blockEl = e.target.closest('.editor-block'); 2925 const blockId = blockEl?.dataset?.id; 2926 2927 if (longPressTimer) clearTimeout(longPressTimer); 2928 longPressTimer = setTimeout(() => { 2929 isSelecting = true; 2930 dragSelectionActive = true; 2931 initialSelectedIds = new Set(selection.ids); 2932 editorEl.classList.add('dragging-selection'); 2933 2934 if (blockId) { 2935 selection.add(blockId); 2936 } 2937 2938 try { 2939 editorEl.setPointerCapture(e.pointerId); 2940 } catch (err) {} 2941 2942 if (navigator.vibrate) navigator.vibrate(10); 2943 }, 250); 2944 }); 2945 2946 editorEl.addEventListener('pointermove', (e) => { 2947 if (longPressTimer) { 2948 const dist = Math.hypot(e.clientX - startX, e.clientY - startY); 2949 if (dist > 10) { 2950 clearTimeout(longPressTimer); 2951 longPressTimer = null; 2952 } 2953 } 2954 2955 if (!isPointerDown) return; 2956 2957 if (!dragSelectionActive) { 2958 const distY = Math.abs(e.clientY - startY); 2959 const distX = Math.abs(e.clientX - startX); 2960 2961 let shouldStartBlockSelection = false; 2962 2963 if (startEditable) { 2964 // Started in focused text: only switch to block selection if dragged vertically outside the block's bounds 2965 const rect = startEditable.getBoundingClientRect(); 2966 if (e.clientY < rect.top - 5 || e.clientY > rect.bottom + 5) { 2967 shouldStartBlockSelection = true; 2968 } 2969 } else { 2970 // Started in non-focused text or empty space: start block selection immediately 2971 if (Math.hypot(distX, distY) > 5) { 2972 shouldStartBlockSelection = true; 2973 } 2974 } 2975 2976 if (shouldStartBlockSelection) { 2977 if (longPressTimer) { 2978 clearTimeout(longPressTimer); 2979 longPressTimer = null; 2980 } 2981 dragSelectionActive = true; 2982 isSelecting = true; 2983 editorEl.classList.add('dragging-selection'); 2984 2985 const sel = window.getSelection(); 2986 if (sel) sel.removeAllRanges(); 2987 2988 if (!e.shiftKey && !e.ctrlKey && !e.metaKey) { 2989 selection.clear(); 2990 initialSelectedIds = new Set(); 2991 } 2992 try { editorEl.setPointerCapture(e.pointerId); } catch(err){} 2993 } 2994 } 2995 2996 if (dragSelectionActive) { 2997 e.preventDefault(); 2998 2999 const currentY = e.clientY; 3000 const minY = Math.min(startY, currentY); 3001 const maxY = Math.max(startY, currentY); 3002 3003 const allBlockEls = Array.from(editorEl.querySelectorAll('.editor-block')); 3004 const newSelection = new Set(initialSelectedIds); 3005 allBlockEls.forEach(el => { 3006 const rect = el.getBoundingClientRect(); 3007 if (rect.bottom >= minY && rect.top <= maxY) { 3008 newSelection.add(el.dataset.id); 3009 } 3010 }); 3011 selection.ids = newSelection; 3012 selection.updateVisuals(); 3013 } 3014 }); 3015 3016 editorEl.addEventListener('selectstart', (e) => { 3017 if (dragSelectionActive || (isPointerDown && !startEditable)) { 3018 e.preventDefault(); 3019 } 3020 }); 3021 3022 const endSelection = (e) => { 3023 if (longPressTimer) { 3024 clearTimeout(longPressTimer); 3025 longPressTimer = null; 3026 } 3027 isPointerDown = false;
3028 if (dragSelectionActive) { 3029 dragSelectionActive = false; 3030 isSelecting = false; 3031 ignoredSelectionClick = { 3032 clientX: e.clientX, 3033 clientY: e.clientY, 3034 expiresAt: performance.now() + 250 3035 }; 3036 editorEl.classList.remove('dragging-selection'); 3037 try { 3038 editorEl.releasePointerCapture(e.pointerId); 3039 } catch (err) {} 3040 } 3041 }; 3042 3043 editorEl.addEventListener('pointerup', endSelection); 3044 editorEl.addEventListener('pointercancel', endSelection); 3045 3046 // 1. Validate Intent on MouseDown 3047 editorEl.addEventListener('mousedown', (e) => { 3048 const handle = e.target.closest('.block-handle'); 3049 /* 3050 User Req: 3051 - Click gutter (handle) -> select 3052 - Shift+Click -> range 3053 - Ctrl+Click -> toggle 3054 - Drag -> normal DnD 3055 */ 3056 if (handle) { 3057 dragAllowed = true; 3058 isDragOp = false; 3059 3060 const blockEl = handle.closest('.editor-block'); 3061 if (blockEl) { 3062 blockEl.setAttribute('draggable', 'true'); 3063 } 3064 const blockId = blockEl ? blockEl.dataset.id : null; 3065 3066 const allBlockIds = Array.from(editorEl.querySelectorAll('.editor-block')).map(el => el.dataset.id); 3067 3068 if (e.shiftKey) { 3069 e.preventDefault(); // Prevent text selection 3070 selection.setRange(blockId, allBlockIds); 3071 } else if (e.ctrlKey || e.metaKey) { 3072 e.preventDefault(); 3073 selection.toggle(blockId); 3074 } else { 3075 // Normal click: 3076 // If already selected, don't clear (might be preparing to drag multiple) 3077 // If not selected, select single. 3078 if (!selection.ids.has(blockId)) { 3079 selection.setSingle(blockId); 3080 } 3081 // Note: If I click an already selected block, I might want to de
3081select others ON MOUSEUP if no drag happened. 3082 // For now simplicity: Click selects single. 3083 } 3084 } else { 3085 dragAllowed = false; 3086 // Clicked outside handle (e.g. text or blank)? Clear selection if not Shift/Ctrl 3087 if (!e.target.closest('.block-plus') && !e.target.closest('.block-handle')) { 3088 // Check if click is in text (don't clear if text interaction?) 3089 // User says: "Click dans une zone vide (hors blocs) : clear selection" -> Handle in container click 3090 // User says: "ne pas casser la sélection de texte à lâintérieur du bloc" 3091 // If click is in editable-text, we shouldn't clear unless we want to? 3092 // Notion clears block selection when editing text. 3093 if (e.target.classList.contains('editable-text') || e.target.closest('.editable-text')) { 3094 selection.clear(); 3095 } 3096 } 3097 } 3098 }); 3099 3100 editorEl.addEventListener('mouseup', (e) => { 3101 editorEl.querySelectorAll('.editor-block').forEach(el => { 3102 el.setAttribute('draggable', 'false'); 3103 }); 3104 }); 3105 3106 // 2. Start Dragging 3107 editorEl.addEventListener('dragstart', (e) => { 3108 if (!dragAllowed) { 3109 e.preventDefault(); 3110 return; 3111 } 3112 3113 isDragOp = true; 3114 const blockEl = e.target.closest('.editor-block'); 3115 if (!blockEl) return; 3116 3117 // If dragging a selected block, drag all selected? 3118 // For now, simpler: Just drag the source block. 3119 // Future TODO: Multi-drag. 3120 3121 dragSrcId = blockEl.dataset.id; 3122 e.dataTransfer.effectAllowed = 'copyMove'; // Allow copy to other blocks 3123 e.dataTransfer.setData('text/plain', dragSrcId); // Keep for internal sort 3124 3125 // Export Rich Data for external drops (e.g. To Do block) 3126 const allBlocks = store.getBlocks(selectedPageId); 3127 const srcIdx = allBlocks.findIndex(b => String(b.id) === dragSrcId); 3128 3129 if (srcIdx !== -1) { 3130 const rootBlock = allBlocks[srcIdx]; 3131 const exportItems = []; 3132 3133 // Add Root 3134 const project = store.getPage(selectedPageId); 3135 exportItems.push({ 3136 origin: 'objectifs', 3137 id: rootBlock.id, 3138 type: rootBlock.type, 3139 text: rootBlock.text, 3140 checked: rootBlock.checked, 3141 projectId: selectedPageId, 3142 projectName: project ? store.getPagePath(project.id) : '', 3143 projectIcon: project ? project.icon : '', 3144 estimatedMinutes: rootBlock.estimatedMinutes || rootBlock.estimated_minutes || rootBlock.duration_minutes || null, 3145 level: 0 // Root is level 0 relative to export 3146 }); 3147 3148 // Add Descendants 3149 const rootLevel = rootBlock.level || 0; 3150 for (let i = srcIdx + 1; i < allBlocks.length; i++) { 3151 const b = allBlocks[i]; 3152 const bLevel = b.level || 0; 3153 if (bLevel > rootLevel) { 3154 exportItems.push({ 3155 origin: 'objectifs', 3156 id: b.id, 3157 type: b.type, 3158 text: b.text, 3159 checked: b.checked, 3160 projectId: selectedPageId, 3161 projectName: project ? store.getPagePath(project.id) : '', 3162 projectIcon: project ? project.icon : '', 3163 estimatedMinutes: b.estimatedMinutes || b.estimated_minutes || b.duration_minutes || null, 3164 level: bLevel - rootLevel // Normalize relative to root 3165 }); 3166 } else { 3167 break; // Not a child anymore 3168 } 3169 } 3170 3171 e.dataTransfer.setData('application/json', JSON.stringify(exportItems)); 3172 window.__roximaDraggingTask = { 3173 text: rootBlock.text, 3174 id: rootBlock.id, 3175 origin: 'projects' 3176 }; 3177 } 3178 3179 requestAnimationFrame(() => blockEl.classList.add('drag-source')); 3180 }); 3181 3182 editorEl.addEventListener('dragend', (e) => { 3183 dragAllowed = false; // Reset 3184 window.__roximaDraggingTask = null; 3185 const blockEl = e.target.closest('.editor-block'); 3186 if (blockEl) { 3187 blockEl.classList.remove('drag-source'); 3188 blockEl.setAttribute('draggable', 'false'); 3189 } 3190 3191 // Clean up all borders 3192 editorEl.querySelectorAll('.drag-over-top, .drag-over-bottom').forEach(el => { 3193 el.classList.remove('drag-over-top'); 3194 el.classList.remove('drag-over-bottom'); 3195 }); 3196 }); 3197 3198 editorEl.addEventListener('dragover', (e) => { 3199 e.preventDefault(); // Necessary to allow dropping 3200 const targetBlock = e.target.closest('.editor-block'); 3201 if (!targetBlock || targetBlock.dataset.id === dragSrcId) return; 3202 3203 const rect = targetBlock.getBoundingClientRect(); 3204 const offset = e.clientY - rect.top; 3205 const height = rect.height; 3206 3207 // Remove existing classes first 3208 editorEl.querySelectorAll('.drag-over-top, .drag-over-bottom').forEach(el => { 3209 if (el !== targetBlock) { 3210 el.classList.remove('drag-over-top'); 3211 el.classList.remove('drag-over-bottom'); 3212 } 3213 }); 3214 3215 if (offset < height / 2) { 3216 targetBlock.classList.add('drag-over-top'); 3217 targetBlock.classList.remove('drag-over-bottom'); 3218 } else { 3219 targetBlock.classList.add('drag-over-bottom'); 3220 targetBlock.classList.remove('drag-over-top'); 3221 } 3222 }); 3223 3224 editorEl.addEventListener('dragleave', (e) => { 3225 // We can't clear immediately because dragleave fires when entering a child. 3226 // We rely on dragover to clear others. 3227 }); 3228 3229 editorEl.addEventListener('drop', (e) => { 3230 e.stopPropagation(); 3231 const targetBlock = e.target.closest('.editor-block'); 3232 if (!targetBlock || !dragSrcId) return; 3233 3234 const targetId = targetBlock.dataset.id; 3235 if (dragSrcId === targetId) return; 3236 3237 // Determine position 3238 let position = 'after'; // default 3239 if (targetBlock.classList.contains('drag-over-top')) { 3240 position = 'before'; 3241 } 3242 3243 store.moveBlock(selectedPageId, dragSrcId, targetId, position); 3244 render(); // Re-render whole list 3245 }); 3246 } 3247 3248 function initDragDropSystem(editorEl, store) { 3249 if (!editorEl) return; 3250 3251 const scroller = editorEl.closest('.obj-content') || editorEl; 3252 const selectionSurface = editorEl.closest('.card') || scroller; 3253 3254 let dragAllowed = false;
3255 let dragSrcIds = []; 3256 let dragMovingIds = new Set(); 3257 let isPointerDown = false; 3258 let pointerId = null; 3259 let startClientX = 0; 3260 let startClientY = 0; 3261 let startPoint = null; 3262 let lastClientPoint = null; 3263 let startBlockId = null; 3264 let startEditable = null; 3265 let canStartMarquee = false; 3266 let dragSelectionActive = false; 3267 let initialSelectedIds = new Set(); 3268 let longPressTimer = null; 3269 let marqueeEl = null; 3270 let marqueeViewportEl = null; 3271 3272 const allEditorIds = () => Array.from(editorEl.querySelectorAll('.editor-block'), el => el.dataset.id); 3273 const removeMarquee = () => { 3274 marqueeViewportEl?.remove(); 3275 marqueeEl = null; 3276 marqueeViewportEl = null; 3277 }; 3278 const getSelectionGeometry = () => ({ 3279 surfaceRect: selectionSurface.getBoundingClientRect(), 3280 scrollportRect: scroller.getBoundingClientRect(), 3281 scrollTop: scroller.scrollTop 3282 }); 3283 const getSelectionPoint = (clientX, clientY, geometry = getSelectionGeometry()) => ( 3284 clientPointToSelectionSpace({ clientX, clientY }, geometry.surfaceRect, geometry.scrollportRect, geometry.scrollTop) 3285 ); 3286 const isInsideSelectionSurface = (event) => { 3287 const { surfaceRect, scrollportRect } = getSelectionGeometry(); 3288 return event.clientX >= surfaceRect.left 3289 && event.clientX <= surfaceRect.right 3290 && event.clientY >= scrollportRect.top 3291 && event.clientY <= scrollportRect.bottom; 3292 }; 3293 const showMarquee = (clientX, clientY) => { 3294 const geometry = getSelectionGeometry(); 3295 const currentPoint = getSelectionPoint(clientX, clientY, geometry); 3296 const marqueeRect = createSelectionRect(startPoint, currentPoint); 3297 if (!marqueeRect) return null; 3298 3299 if (!marqueeViewportEl) { 3300 marqueeViewportEl = document.createElement('div'); 3301 marqueeViewportEl.className = 'project-selection-marquee-viewport'; 3302 marqueeViewportEl.setAttribute('aria-hidden', 'true'); 3303 marqueeEl = document.createElement('div'); 3304 marqueeEl.className = 'project-selection-marquee'; 3305 marqueeViewportEl.appendChild(marqueeEl); 3306 document.body.appendChild(marqueeViewportEl); 3307 } 3308 3309 Object.assign(marqueeViewportEl.style, { 3310 left: `${geometry.surfaceRect.left}px`, 3311 top: `${geometry.scrollportRect.top}px`, 3312 width: `${geometry.surfaceRect.width}px`, 3313 height: `${geometry.scrollportRect.height}px` 3314 }); 3315 Object.assign(marqueeEl.style, { 3316 left: `${marqueeRect.left}px`, 3317 top: `${marqueeRect.top - geometry.scrollTop}px`, 3318 width: `${marqueeRect.right - marqueeRect.left}px`, 3319 height: `${marqueeRect.bottom - marqueeRect.top}px` 3320 }); 3321 return { marqueeRect, geometry }; 3322 }; 3323 const updateMarqueeSelection = (clientX, clientY) => { 3324 lastClientPoint = { clientX, clientY }; 3325 const marqueeState = showMarquee(clientX, clientY); 3326 if (!marqueeState) return; 3327 3328 const newSelection = new Set(initialSelectedIds); 3329 editorEl.querySelectorAll('.editor-block').forEach(el => { 3330 const blockRect = clientRectToSelectionSpace( 3331 el.getBoundingClientRect(), 3332 marqueeState.geometry.surfaceRect, 3333 marqueeState.geometry.scrollportRect, 3334 marqueeState.geometry.scrollTop 3335 ); 3336 if (rectanglesIntersect(marqueeState.marqueeRect, blockRect)) newSelection.add(el.dataset.id); 3337 }); 3338 const orderedSelectedIds = allEditorIds().filter(id => newSelection.has(id)); 3339 const firstSelectedId = orderedSelectedIds[0] || startBlockId; 3340 selection.replace(newSelection, { 3341 anchorId: startBlockId || firstSelectedId, 3342 extentId: orderedSelectedIds.at(-1) || firstSelectedId 3343 }); 3344 }; 3345 const beginMarquee = (event) => { 3346 dragSelectionActive = true; 3347 editorEl.classList.add('dragging-selection'); 3348 window.getSelection()?.removeAllRanges(); 3349 if (!event.shiftKey && !event.ctrlKey && !event.metaKey) { 3350 initialSelectedIds = new Set(); 3351 selection.replace([]); 3352 } 3353 try { selectionSurface.setPointerCapture(event.pointerId); } catch (error) {} 3354 }; 3355 3356 selectionSurface.addEventListener('pointerdown', (event) => { 3357 if (event.pointerType === 'mouse' && event.button !== 0) return; 3358 const isInteractive = event.target.closest('.block-controls button, .action-add-sub, .action-del, input, button, a'); 3359 if (isInteractive || !isInsideSelectionSurface(event)) return; 3360 3361 const blockEl = event.target.closest('.editor-block'); 3362 const blockId = blockEl?.dataset?.id || null; 3363 const hasToggleModifier = event.ctrlKey || event.metaKey; 3364 if (blockId && (event.shiftKey || hasToggleModifier)) { 3365 event.preventDefault(); 3366 if (event.shiftKey) { 3367 selection.setRange(blockId, allEditorIds(), { additive: hasToggleModifier }); 3368 } else { 3369 selection.toggle(blockId); 3370 } 3371 return; 3372 } 3373 3374 isPointerDown = true; 3375 pointerId = event.pointerId; 3376 startClientX = event.clientX; 3377 startClientY = event.clientY; 3378 startPoint = getSelectionPoint(event.clientX, event.clientY); 3379 lastClientPoint = { clientX: event.clientX, clientY: event.clientY }; 3380 startBlockId = blockId; 3381 startEditable = event.target.closest('.editable-text'); 3382 // Contenteditable always wins over marquee selection. This keeps an
3383 // empty block immediately clickable and prevents tiny mouse moves 3384 // from turning a text-editing intent into a block selection. 3385 canStartMarquee = !startEditable; 3386 initialSelectedIds = new Set(selection.ids); 3387 3388 if (event.pointerType !== 'mouse') { 3389 longPressTimer = setTimeout(() => { 3390 canStartMarquee = true; 3391 beginMarquee(event); 3392 if (blockId) selection.add(blockId, { preserveAnchor: true }); 3393 navigator.vibrate?.(10); 3394 }, 300); 3395 } 3396 }); 3397 3398 selectionSurface.addEventListener('pointermove', (event) => { 3399 if (!isPointerDown || event.pointerId !== pointerId) return; 3400 const distance = Math.hypot(event.clientX - startClientX, event.clientY - startClientY); 3401 if (longPressTimer && distance > 10) { 3402 clearTimeout(longPressTimer); 3403 longPressTimer = null; 3404 } 3405 if (!dragSelectionActive && canStartMarquee && distance > 5) beginMarquee(event); 3406 if (!dragSelectionActive) return; 3407 3408 event.preventDefault(); 3409 updateMarqueeSelection(event.clientX, event.clientY); 3410 3411 const rect = scroller.getBoundingClientRect(); 3412 if (event.clientY < rect.top + 36) scroller.scrollBy({ top: -12 }); 3413 if (event.clientY > rect.bottom - 36) scroller.scrollBy({ top: 12 }); 3414 }); 3415 3416 scroller.addEventListener('scroll', () => { 3417 if (!dragSelectionActive || !lastClientPoint) return; 3418 updateMarqueeSelection(lastClientPoint.clientX, lastClientPoint.clientY); 3419 }); 3420 3421 selectionSurface.addEventListener('selectstart', (event) => { 3422 if (dragSelectionActive || (isPointerDown && canStartMarquee)) event.preventDefault(); 3423 }); 3424 3425 const endSelection = (event) => { 3426 if (longPressTimer) clearTimeout(longPressTimer); 3427 longPressTimer = null; 3428 isPointerDown = false; 3429 pointerId = null; 3430 if (!dragSelectionActive) return; 3431 dragSelectionActive = false; 3432 if (event.type === 'pointerup') { 3433 ignoredSelectionClick = { 3434 clientX: event.clientX, 3435 clientY: event.clientY, 3436 expiresAt: performance.now() + 250 3437 }; 3438 } 3439 editorEl.classList.remove('dragging-selection'); 3440 removeMarquee(); 3441 try { selectionSurface.releasePointerCapture(event.pointerId); } catch (error) {} 3442 }; 3443 selectionSurface.addEventListener('pointerup', endSelection); 3444 selectionSurface.addEventListener('pointercancel', endSelection); 3445 3446 editorEl.addEventListener('mousedown', (event) => { 3447 const handle = event.target.closest('.block-handle'); 3448 if (!handle) { 3449 dragAllowed = false; 3450 return; 3451 } 3452 3453 dragAllowed = true; 3454 const blockEl = handle.closest('.editor-block'); 3455 if (!blockEl) return; 3456 blockEl.setAttribute('draggable', 'true'); 3457 const blockId = blockEl.dataset.id; 3458 if (event.shiftKey) { 3459 event.preventDefault(); 3460 selection.setRange(blockId, allEditorIds(), { additive: event.ctrlKey || event.metaKey }); 3461 } else if (event.ctrlKey || event.metaKey) { 3462 event.preventDefault(); 3463 selection.toggle(blockId); 3464 } else if (!selection.ids.has(blockId)) { 3465 selection.setSingle(blockId); 3466 } 3467 }); 3468 3469 editorEl.addEventListener('click', (event) => { 3470 const blockEl = event.target.closest('.editor-block'); 3471 if (!blockEl) return; 3472 3473 const modifiedBlockSelection = (event.shiftKey || event.ctrlKey || event.metaKey) 3474 && (event.target.closest('.block-handle') || !event.target.closest('button, input, a')); 3475 if (modifiedBlockSelection) { 3476 event.preventDefault(); 3477 event.stopImmediatePropagation(); 3478 return; 3479 } 3480 if (event.target.closest('.editable-text')) { 3481 selection.clear(); 3482 return; 3483 } 3484 if (event.target.closest('button, input, a')) return; 3485 if (!event.shiftKey && !event.ctrlKey && !event.metaKey) selection.setSingle(blockEl.dataset.id); 3486 }, true); 3487 3488 editorEl.addEventListener('mouseup', () => { 3489 editorEl.querySelectorAll('.editor-block').forEach(el => el.setAttribute('draggable', 'false')); 3490 }); 3491 3492 editorEl.addEventListener('dragstart', (event) => { 3493 if (!dragAllowed) { 3494 event.preventDefault(); 3495 return; 3496 } 3497 const blockEl = event.target.closest('.editor-block'); 3498 if (!blockEl) return; 3499 const dragSrcId = blockEl.dataset.id; 3500 dragSrcIds = selection.ids.has(dragSrcId) 3501 ? selection.getSortedIds(allEditorIds()) 3502 : [dragSrcId]; 3503 3504 const allBlocks = store.getBlocks(selectedPageId); 3505 dragMovingIds = new Set(getSelectionWithDescendants(allBlocks, dragSrcIds).map(block => String(block.id))); 3506 const project = store.getPage(selectedPageId); 3507 const exportItems = createProjectDragItems(allBlocks, dragSrcIds, { 3508 id: selectedPageId, 3509 name: project ? store.getPagePath(project.id) : '', 3510 icon: project?.icon || '' 3511 }); 3512 3513 event.dataTransfer.effectAllowed = 'copyMove'; 3514 event.dataTransfer.setData('text/plain', dragSrcIds.join(',')); 3515 event.dataTransfer.setData('application/json', JSON.stringify(exportItems)); 3516 window.__roximaDraggingTasks = exportItems; 3517 window.__roximaDraggingTask = exportItems.length ? { 3518 ...exportItems[0], 3519 origin: 'projects', 3520 count: exportItems.length 3521 } : null; 3522 3523 if (dragSrcIds.length > 1) { 3524 const ghost = document.createElement('div'); 3525 ghost.className = 'project-multi-drag-ghost'; 3526 ghost.textContent = `${dragSrcIds.length} ${i18n.t('selection_items_selected')}`; 3527 document.body.appendChild(ghost); 3528 event.dataTransfer.setDragImage(ghost, 18, 18); 3529 setTimeout(() => ghost.remove(), 0); 3530 } 3531 requestAnimationFrame(() => { 3532 editorEl.querySelectorAll('.editor-block').forEach(el => { 3533 el.classList.toggle('drag-source', dragMovingIds.has(el.dataset.id)); 3534 }); 3535 }); 3536 }); 3537 3538 editorEl.addEventListener('dragend', () => { 3539 dragAllowed = false;
3540 dragSrcIds = []; 3541 dragMovingIds.clear(); 3542 window.__roximaDraggingTask = null; 3543 window.__roximaDraggingTasks = null; 3544 editorEl.querySelectorAll('.drag-source, .drag-over-top, .drag-over-bottom').forEach(el => { 3545 el.classList.remove('drag-source', 'drag-over-top', 'drag-over-bottom'); 3546 el.setAttribute('draggable', 'false'); 3547 }); 3548 }); 3549 3550 editorEl.addEventListener('dragover', (event) => { 3551 event.preventDefault(); 3552 const targetBlock = event.target.closest('.editor-block'); 3553 if (!targetBlock || dragMovingIds.has(targetBlock.dataset.id)) return; 3554 editorEl.querySelectorAll('.drag-over-top, .drag-over-bottom').forEach(el => { 3555 if (el !== targetBlock) el.classList.remove('drag-over-top', 'drag-over-bottom'); 3556 }); 3557 const rect = targetBlock.getBoundingClientRect(); 3558 const before = event.clientY < rect.top + rect.height / 2; 3559 targetBlock.classList.toggle('drag-over-top', before); 3560 targetBlock.classList.toggle('drag-over-bottom', !before); 3561 }); 3562 3563 editorEl.addEventListener('drop', (event) => { 3564 event.preventDefault(); 3565 event.stopPropagation(); 3566 const targetBlock = event.target.closest('.editor-block'); 3567 if (!targetBlock || !dragSrcIds.length || dragMovingIds.has(targetBlock.dataset.id)) return; 3568 const position = targetBlock.classList.contains('drag-over-top') ? 'before' : 'after'; 3569 const movedIds = store.moveBlocks(selectedPageId, dragSrcIds, targetBlock.dataset.id, position); 3570 if (movedIds.length) render(); 3571 }); 3572 } 3573 3574 function createFormattingToolbar() { 3575 if (formattingToolbar) return; 3576 3577 formattingToolbar = document.createElement('div'); 3578 formattingToolbar.className = 'notion-formatting-toolbar'; 3579 3580 formattingToolbar.innerHTML = ` 3581 <button class="format-btn bold" type="button" title="Gras (Ctrl+B)">B</button> 3582 <button class="format-btn italic" type="button" title="Italique (Ctrl+I)">I</button> 3583 <button class="format-btn underline" type="button" title="Souligné (Ctrl+U)">U</button> 3584 <button class="format-btn strike" type="button" title="Barré">S</button> 3585 <button class="format-btn code" type="button" title="Code en ligne"></></button> 3586 <div class="format-divider" style="width: 1px; height: 16px; background: var(--border-color, #e2e8f0); margin: 0 4px;"></div> 3587 <button class="format-btn color-picker" type="button" title="Couleur"> 3588 <span class="color-indicator">A</span> 3589 <span style="font-size: 8px; margin-left: 2px;">â¼</span> 3590 </button> 3591 `; 3592 3593 document.body.appendChild(formattingToolbar); 3594 3595 // Bind events 3596 formattingToolbar.querySelector('.bold').addEventListener('mousedown', (e) => { 3597 e.preventDefault(); 3598 document.execCommand('bold', false, null); 3599 triggerTextChange(); 3600 }); 3601 formattingToolbar.querySelector('.italic').addEventListener('mousedown', (e) => { 3602 e.preventDefault(); 3603 document.execCommand('italic', false, null); 3604 triggerTextChange(); 3605 }); 3606 formattingToolbar.querySelector('.underline').addEventListener('mousedown', (e) => { 3607 e.preventDefault(); 3608 document.execCommand('underline', false, null); 3609 triggerTextChange(); 3610 }); 3611 formattingToolbar.querySelector('.strike').addEventListener('mousedown', (e) => { 3612 e.preventDefault(); 3613 document.execCommand('strikeThrough', false, null); 3614 triggerTextChange(); 3615 }); 3616 formattingToolbar.querySelector('.code').addEventListener('mousedown', (e) => { 3617 e.preventDefault(); 3618 wrapSelectionInTag('code'); 3619 triggerTextChange(); 3620 }); 3621 3622 const colorBtn = formattingToolbar.querySelector('.color-picker'); 3623 colorBtn.addEventListener('mousedown', (e) => { 3624 e.preventDefault(); 3625 e.stopPropagation(); 3626 toggleColorDropdown(); 3627 }); 3628 } 3629 3630 function triggerTextChange() { 3631 const activeEl = document.activeElement; 3632 if (activeEl && activeEl.classList.contains('editable-text')) { 3633 const event = new Event('input', { bubbles: true }); 3634 activeEl.dispatchEvent(event); 3635 } 3636 } 3637 3638 function wrapSelectionInTag(tagName, className = '') { 3639 const selection = window.getSelection(); 3640 if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return; 3641 3642 const range = selection.getRangeAt(0); 3643 let parent = range.commonAncestorContainer; 3644 if (parent.nodeType === 3) parent = parent.parentNode; 3645 3646 const existingNode = parent.closest(tagName); 3647 if (existingNode && (!className || existingNode.className === className)) { 3648 const textNode = document.createTextNode(existingNode.textContent); 3649 existingNode.parentNode.replaceChild(textNode, existingNode); 3650 return; 3651 } 3652 3653 const el = document.createElement(tagName); 3654 if (className) el.className = className; 3655 3656 try { 3657 el.appendChild(range.extractContents());
3658 range.insertNode(el); 3659 3660 const newRange = document.createRange(); 3661 newRange.selectNodeContents(el); 3662 selection.removeAllRanges(); 3663 selection.addRange(newRange); 3664 } catch (err) { 3665 console.error("Failed to wrap selection", err); 3666 } 3667 } 3668 3669 function toggleColorDropdown() { 3670 if (currentColorDropdown) { 3671 currentColorDropdown.remove(); 3672 currentColorDropdown = null; 3673 return; 3674 } 3675 3676 currentColorDropdown = document.createElement('div'); 3677 currentColorDropdown.className = 'notion-color-dropdown'; 3678 3679 const colors = [ 3680 { name: 'Défaut', value: 'default', color: 'inherit', bg: 'transparent' }, 3681 { name: 'Gris', value: 'gray', color: '#8a8986', bg: 'transparent' }, 3682 { name: 'Marron', value: 'brown', color: '#64473a', bg: 'transparent' }, 3683 { name: 'Orange', value: 'orange', color: '#d9730d', bg: 'transparent' }, 3684 { name: 'Jaune', value: 'yellow', color: '#dfab01', bg: 'transparent' }, 3685 { name: 'Vert', value: 'green', color: '#0f7b4b', bg: 'transparent' }, 3686 { name: 'Bleu', value: 'blue', color: '#0b6e9f', bg: 'transparent' }, 3687 { name: 'Violet', value: 'purple', color: '#6940a5', bg: 'transparent' }, 3688 { name: 'Rose', value: 'pink', color: '#ad1a72', bg: 'transparent' }, 3689 { name: 'Rouge', value: 'red', color: '#e03e3e', bg: 'transparent' } 3690 ]; 3691 3692 const bgColors = [ 3693 { name: 'Fond Gris', value: 'gray_background', color: 'inherit', bg: 'rgba(138, 137, 134, 0.15)' }, 3694 { name: 'Fond Marron', value: 'brown_background', color: 'inherit', bg: 'rgba(100, 71, 58, 0.15)' }, 3695 { name: 'Fond Orange', value: 'orange_background', color: 'inherit', bg: 'rgba(217, 115, 13, 0.15)' }, 3696 { name: 'Fond Jaune', value: 'yellow_background', color: 'inherit', bg: 'rgba(223, 171, 1, 0.15)' }, 3697 { name: 'Fond Vert', value: 'green_background', color: 'inherit', bg: 'rgba(15, 123, 75, 0.15)' }, 3698 { name: 'Fond Bleu', value: 'blue_background', color: 'inherit', bg: 'rgba(11, 110, 159, 0.15)' }, 3699 { name: 'Fond Violet', value: 'purple_background', color: 'inherit', bg: 'rgba(105, 64, 165, 0.15)' }, 3700 { name: 'Fond Rose', value: 'pink_background', color: 'inherit', bg: 'rgba(173, 26, 114, 0.15)' }, 3701 { name: 'Fond Rouge', value: 'red_background', color: 'inherit', bg: 'rgba(224, 62, 62, 0.15)' } 3702 ]; 3703 3704 let html = '<div style="font-size: 10px; font-weight: 600; color: var(--text-muted); padding: 2px 4px 6px;">COULEUR</div>'; 3705 colors.forEach(c => { 3706 html += `<button class="color-opt-btn" type="button" data-val="${c.value}" style="color: ${c.color}; text-align: left; background: none; border: none; padding: 4px 8px; border-radius: 4px; font-size: 13px; font-weight: 500; cursor: pointer; display: flex; align-items: center; gap: 6px;">${c.name}</button>`; 3707 }); 3708 3709 html += '<div style="font-size: 10px; font-weight: 600; color: var(--text-muted); padding: 8px 4px 6px;">ARRIÃRE-PLAN</div>'; 3710 bgColors.forEach(c => { 3711 html += `<button class="color-opt-btn" type="button" data-val="${c.value}" style="text-align: left; background: none; border: none; padding: 4px 8px; border-radius: 4px; font-size: 13px; font-weight: 500; cursor: pointer; display: flex; align-items: center; gap: 6px;"><span style="background: ${c.bg}; padding: 0 4px; border-radius: 2px; color: ${c.color}">A</span> ${c.name}</button>`; 3712 }); 3713 3714 currentColorDropdown.innerHTML = html; 3715 3716 const rect = formattingToolbar.getBoundingClientRect(); 3717 currentColorDropdown.style.left = `${rect.left + window.scrollX}px`; 3718 currentColorDropdown.style.top = `${rect.bottom + window.scrollY + 4}px`; 3719 3720 document.body.appendChild(currentColorDropdown); 3721 3722 currentColorDropdown.querySelectorAll('.color-opt-btn').forEach(btn => { 3723 btn.addEventListener('mousedown', (e) => { 3724 e.preventDefault(); 3725 e.stopPropagation(); 3726 const colorVal = btn.dataset.val; 3727 3728 if (colorVal === 'default') { 3729 const selection = window.getSelection(); 3730 if (selection.rangeCount > 0) { 3731 const range = selection.getRangeAt(0); 3732 let parent = range.commonAncestorContainer; 3733 if (parent.nodeType === 3) parent = parent.parentNode; 3734 const span = parent.closest('span[class*="notion-color-"]'); 3735 if (span) { 3736 const textNode = document.createTextNode(span.textContent); 3737 span.parentNode.replaceChild(textNode, span); 3738 } 3739 } 3740 } else { 3741 wrapSelectionInTag('span', `notion-color-${colorVal}`); 3742 } 3743 3744 triggerTextChange(); 3745 closeColorDropdown(); 3746 hideFormattingToolbar(); 3747 }); 3748 }); 3749 } 3750 3751 function closeColorDropdown() { 3752 if (currentColorDropdown) { 3753 currentColorDropdown.remove(); 3754 currentColorDropdown = null; 3755 } 3756 } 3757 3758 function monitorSelection() { 3759 createFormattingToolbar(); 3760 3761 const handleSelectionChange = () => { 3762 const selection = window.getSelection(); 3763 if (selection.isCollapsed || selection.rangeCount === 0 || selection.toString().trim() === '') { 3764 hideFormattingToolbar(); 3765 return; 3766 } 3767 3768 const range = selection.getRangeAt(0); 3769 let parent = range.commonAncestorContainer; 3770 if (parent.nodeType === 3) parent = parent.parentNode; 3771 3772 const editableBlock = parent.closest('.editable-text'); 3773 if (!editableBlock) { 3774 hideFormattingToolbar(); 3775 return; 3776 } 3777 3778 const rects = range.getClientRects(); 3779 if (rects.length > 0) { 3780 const rect = rects[0]; 3781 formattingToolbar.style.display = 'flex'; 3782 3783 const toolbarWidth = formattingToolbar.offsetWidth || 230; 3784 const toolbarHeight = formattingToolbar.offsetHeight || 38; 3785 3786 let left = rect.left + rect.width / 2 - toolbarWidth / 2 + window.scrollX; 3787 let top = rect.top - toolbarHeight - 8 + window.scrollY; 3788 3789 left = Math.max(8, left); 3790 if (left + toolbarWidth > window.innerWidth) { 3791 left = window.innerWidth - toolbarWidth - 8; 3792 } 3793 top = Math.max(8, top); 3794 3795 formattingToolbar.style.left = `${left}px`; 3796 formattingToolbar.style.top = `${top}px`; 3797 } 3798 }; 3799 3800 document.addEventListener('mouseup', () => { 3801 setTimeout(handleSelectionChange, 10); 3802 }); 3803 3804 document.addEventListener('keyup', (e) => { 3805 if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Shift') { 3806 setTimeout(handleSelectionChange, 10); 3807 } 3808 }); 3809 3810 document.addEventListener('mousedown', (e) => { 3811 if (formattingToolbar && !formattingToolbar.contains(e.target) && (!currentColorDropdown || !currentColorDropdown.contains(e.target))) { 3812 hideFormattingToolbar(); 3813 } 3814 }); 3815 } 3816 3817 function hideFormattingToolbar() { 3818 if (formattingToolbar) { 3819 formattingToolbar.style.display = 'none'; 3820 } 3821 closeColorDropdown(); 3822 } 3823 3824 function parseMarkdownToHtml(text) { 3825 if (!text) return ''; 3826 let html = escapeHtml(String(text)); 3827 3828 // Match **bold** -> <strong>bold</strong> 3829 let parts = html.split('**'); 3830 let parsed = ''; 3831 for (let i = 0; i < parts.length; i++) { 3832 if (i % 2 === 1) { 3833 parsed += `<strong>${parts[i]}</strong>`; 3834 } else { 3835 parsed += parts[i]; 3836 } 3837 } 3838 3839 // Match *italic* -> <em>italic</em> 3840 let partsItalic = parsed.split('*'); 3841 parsed = ''; 3842 for (let i = 0; i < partsItalic.length; i++) { 3843 if (i % 2 === 1) { 3844 parsed += `<em>${partsItalic[i]}</em>`; 3845 } else { 3846 parsed += partsItalic[i]; 3847 } 3848 } 3849 3850 // Match ~~strike~~ -> <del>strike</del> 3851 let partsStrike = parsed.split('~~'); 3852 parsed = ''; 3853 for (let i = 0; i < partsStrike.length; i++) { 3854 if (i % 2 === 1) { 3855 parsed += `<del>${partsStrike[i]}</del>`; 3856 } else { 3857 parsed += partsStrike[i]; 3858 } 3859 } 3860 3861 // Match `code` -> <code>code</code> 3862 let partsCode = parsed.split('`'); 3863 parsed = ''; 3864 for (let i = 0; i < partsCode.length; i++) { 3865 if (i % 2 === 1) { 3866 parsed += `<code>${partsCode[i]}</code>`; 3867 } else { 3868 parsed += partsCode[i]; 3869 } 3870 } 3871 3872 // Decode safe HTML tags 3873 parsed = parsed.replace(/<u>/gi, '<u>').replace(/<\/u>/gi, '</u>'); 3874 parsed = parsed.replace(/<span class="notion-color-([a-zA-Z_]+)">(.*?)<\/span>/gi, '<span class="notion-color-$1">
3874$2</span>'); 3875 3876 return parsed; 3877 } 3878 3879 function convertHtmlToMarkdown(html) { 3880 if (!html) return ''; 3881 3882 let text = html; 3883 3884 // Strip common browser tags 3885 text = text.replace(/<br>/gi, ''); 3886 text = text.replace(/<div>/gi, '\n').replace(/<\/div>/gi, ''); 3887 text = text.replace(/<p>/gi, '\n').replace(/<\/p>/gi, ''); 3888 3889 // Replace bold tags 3890 text = text.replace(/<(strong|b)[^>]*>(.*?)<\/\1>/gi, '**$2**'); 3891 3892 // Replace italic tags 3893 text = text.replace(/<(em|i)[^>]*>(.*?)<\/\1>/gi, '*$2*'); 3894 3895 // Replace strikethrough tags 3896 text = text.replace(/<(del|s|strike)[^>]*>(.*?)<\/\1>/gi, '~~$2~~'); 3897 3898 // Replace code tags 3899 text = text.replace(/<code[^>]*>(.*?)<\/code>/gi, '`$2`'); 3900 3901 // Keep colors tags clean 3902 text = text.replace(/<span class="notion-color-([a-zA-Z_]+)"[^>]*>(.*?)<\/span>/gi, '<span class="notion-color-$1">$2</span>'); 3903 3904 const tempDiv = document.createElement('div'); 3905 tempDiv.innerHTML = text; 3906 3907 let result = tempDiv.textContent || tempDiv.innerText || ''; 3908 result = result.replace(/\u00a0/g, ' '); // replace with space 3909 return result; 3910 } 3911 3912 function renderBlocks(blocks) { 3913 if (!blocks.length) return ''; 3914 const dailyItems = getDailyItems(); 3915 return blocks.map(block => { 3916 let contentHtml = ''; 3917 let todoQuickActionHtml = ''; 3918 let firstFocusActivationHtml = ''; 3919 const typeClass = `b-${block.type}`; 3920 const blockText = String(block.text || ''); 3921 3922 let escapedBlockText = ''; 3923 if (block.notionData && block.notionData.rich_text) { 3924 escapedBlockText = NotionRichText.toHtml(block.notionData.rich_text); 3925 } else { 3926 escapedBlockText = parseMarkdownToHtml(blockText); 3927 } 3928 3929 const blockIdArg = inlineEventString(block.id); 3930 const blockTextArg = inlineEventString(blockText); 3931 3932 if (block.type === 'divider') { 3933 contentHtml = `<div class="b-divider"></div>`; 3934 } else if (block.type === 'todo') { 3935 const priority = normalizeGeneralPriority(block.generalPriority ?? block.general_priority ?? block.priority); 3936 const priorityMeta = GENERAL_PRIORITY_META[priority]; 3937 const priorityLabel = getGeneralPriorityLabel(priorityMeta); 3938 const alreadyInToday = isBlockInToday(block, dailyItems); 3939 const isFirstFocusActivation = block.id === firstFocusActivationTaskId; 3940 const priorityFlagHtml = ` 3941 <button class="task-priority-flag-button is-${priority}" type="button" data-block-id="${escapeAttribute(block.id)}" 3942 aria-haspopup="menu" aria-expanded="false" 3943 aria-label="${escapeAttribute(`${i18n.t('daily_priority_general')} : ${priorityLabel}`)}" title="${escapeAttribute(`${i18n.t('daily_priority_general')} : ${priorityLabel}`)}"> 3944 <span aria-hidden="true">â</span> 3945 </button> 3946 `; 3947 3948 if (!block.checked && isFirstFocusActivation) { 3949 firstFocusActivationHtml = ` 3950 <div class="first-focus-activation"> 3951 <button class="first-focus-activation-button" type="button" 3952 data-block-id="${escapeAttribute(block.id)}" ${blockText.trim() ? '' : 'disabled'}> 3953 <span data-i18n="${alreadyInToday ? 'project_activation_focus_open_cta' : 'project_activation_focus_cta'}">${i18n.t(alreadyInToday ? 'project_activation_focus_open_cta' : 'project_activation_focus_cta')}</span> 3954 <span aria-hidden="true">â</span> 3955 </button> 3956 <small data-i18n="project_activation_focus_hint">${i18n.t('project_activation_focus_hint')}</small> 3957 </div> 3958 `; 3959 } else if (!block.checked) { 3960 todoQuickActionHtml = `
3961 <button class="add-to-today-button${alreadyInToday ? ' is-added' : ''}" type="button" 3962 data-block-id="${escapeAttribute(block.id)}" 3963 aria-label="${escapeAttribute(i18n.t(alreadyInToday ? 'daily_priority_remove_today_aria' : 'daily_priority_add_today_aria'))}"> 3964 <span class="btn-label-default">${escapeHtml(i18n.t(alreadyInToday ? 'daily_priority_added_today' : 'daily_priority_add_today'))}</span> 3965 ${alreadyInToday ? `<span class="btn-label-hover">â ${escapeHtml(i18n.t('daily_priority_remove'))}</span>` : ''} 3966 </button> 3967 `; 3968 } 3969 3970 contentHtml = ` 3971 <input type="checkbox" class="todo-check" ${block.checked ? 'checked' : ''} data-id="${block.id}"> 3972 ${priorityFlagHtml} 3973 <div class="editable-text ${typeClass} ${block.checked ? 'checked-text' : ''}" 3974 contenteditable="true" 3975 data-id="${block.id}" 3976 placeholder="${escapeHtml(i18n.t('projects_block_todo'))}" 3977 data-i18n-placeholder="projects_block_todo">${escapedBlockText}</div> 3978 `; 3979 } else if (block.type === 'page_link') { 3980 const targetPage = store.getPage(block.text); // block.text holds pageId 3981 if (targetPage) { 3982 contentHtml = ` 3983 <div class="page-link-block" data-page-id="${targetPage.id}" contenteditable="false" style=" 3984 padding: 6px 10px; 3985 background: rgba(0,0,0,0.03); 3986 border-radius: 4px; 3987 cursor: pointer; 3988 display: flex; 3989 align-items: center; 3990 gap: 8px; 3991 width: fit-content; 3992 transition: background 0.2s;"> 3993 <span style="font-size:1.1rem;">${escapeHtml(targetPage.icon || 'ð')}</span> 3994 <span style="border-bottom: 1px solid rgba(0,0,0,0.2); font-weight:500;">${escapeHtml(targetPage.title)}</span> 3995 </div> 3996 `; 3997 } else { 3998 contentHtml = `<div style="color:red; opacity:0.6; font-size:0.8rem;" data-i18n="projects_page_not_found">${i18n.t('projects_page_not_found')}</div>`; 3999 } 4000 } else if (block.type === 'bulleted_list_item') { 4001 contentHtml = ` 4002 <span class="bullet-point" style="margin-right: 8px; font-size: 1.2rem; line-height: 1.5; color: var(--text-muted); user-select: none;">â¢</span> 4003 <div class="editable-text ${typeClass}" 4004 contenteditable="true" 4005 data-id="${block.id}" 4006 placeholder="${escapeHtml(i18n.t('projects_command_placeholder') || 'Taper / pour les commandes')}">${escapedBlockText}</div> 4007 `; 4008 } else if (block.type === 'numbered_list_item') { 4009 contentHtml = ` 4010 <span class="number-point" style="margin-right: 8px; font-size: 1rem; line-height: 1.5; color: var(--text-muted); user-select: none;">1.</span> 4011 <div class="editable-text ${typeClass}" 4012 contenteditable="true" 4013 data-id="${block.id}" 4014 placeholder="${escapeHtml(i18n.t('projects_command_placeholder') || 'Taper / pour les commandes')}">${escapedBlockText}</div> 4015 `; 4016 } else if (block.type === 'quote') { 4017 contentHtml = ` 4018 <div class="quote-bar" style="width: 3px; background: var(--text-muted); margin-right: 12px; border-radius: 2px; align-self: stretch;"></div> 4019 <div class="editable-text ${typeClass}" 4020 contenteditable="true" 4021 data-id="${block.id}" 4022 placeholder="Citation...">${escapedBlockText}</div> 4023 `; 4024 } else if (block.type === 'callout') { 4025 contentHtml = ` 4026 <div class="callout-box" style="display: flex; gap: 12px; background: var(--hover-bg); padding: 12px 16px; border-radius: 6px; width: 100%;">
4027 <span>ð¡</span> 4028 <div class="editable-text ${typeClass}" 4029 contenteditable="true" 4030 data-id="${block.id}" 4031 style="flex: 1; padding: 0;" 4032 placeholder="Texte de mise en évidence...">${escapedBlockText}</div> 4033 </div> 4034 `; 4035 } else if (block.type === 'code') { 4036 contentHtml = ` 4037 <div class="code-box" style="background: rgba(0,0,0,0.05); padding: 12px 16px; border-radius: 6px; width: 100%; font-family: monospace; overflow-x: auto;"> 4038 <div class="editable-text ${typeClass}" 4039 contenteditable="true" 4040 data-id="${block.id}" 4041 style="padding: 0;" 4042 placeholder="Code...">${escapedBlockText}</div> 4043 </div> 4044 `; 4045 } else if (block.type === 'unsupported' || block.type === 'table' || block.type === 'column_list' || block.type === 'column' || block.type === 'image' || block.type === 'video' || block.type === 'bookmark') { 4046 contentHtml = ` 4047 <div class="unsupported-box" style="display: flex; gap: 8px; background: rgba(0,0,0,0.03); padding: 8px 12px; border-radius: 4px; width: 100%; border: 1px dashed rgba(0,0,0,0.1); user-select: none;"> 4048 <span style="opacity:0.6;">ð¦</span> 4049 <div style="flex: 1; opacity:0.6; font-size: 0.9em; display: flex; align-items: center;"> 4050 Bloc ${block.type} (lecture seule) 4051 </div> 4052 </div> 4053 `; 4054 } else { 4055 let placeholderKey = block.type === 'p' ? 'projects_command_placeholder' : ''; 4056 if (block.type === 'h1') placeholderKey = 'projects_block_h1'; 4057 if (block.type === 'h2') placeholderKey = 'projects_block_h2'; 4058 const placeholder = placeholderKey ? i18n.t(placeholderKey) : block.type; 4059 4060 contentHtml = ` 4061 <div class="editable-text ${typeClass}" 4062 contenteditable="true" 4063 data-id="${block.id}" 4064 placeholder="${escapeHtml(placeholder)}" 4065 ${placeholderKey ? `data-i18n-placeholder="${placeholderKey}"` : ''}>${escapedBlockText}</div> 4066 `; 4067 } 4068 4069 const level = block.level || 0; 4070 const indent = level * 24; 4071 4072 const draggableAttr = isTouchLayout() ? 'false' : 'true'; 4073 4074 const blockColor = (block.notionData && block.notionData.color && block.notionData.color !== 'default') ? block.notionData.color : ''; 4075 const blockColorClass = blockColor ? ` notion-color-${blockColor}` : ''; 4076 4077 return ` 4078 <div class="editor-block${blockColorClass}" 4079 data-id="${block.id}" 4080 data-type="${block.type}" 4081 role="option" 4082 aria-selected="false" 4083 draggable="false" 4084 style="padding-left: ${indent}px;"> 4085 <div class="block-controls"> 4086 <button class="block-plus" type="button" data-i18n-title="projects_add_block" data-i18n-aria-label="projects_add_block" 4087 title="${escapeAttribute(i18n.t('projects_add_block'))}" aria-label="${escapeAttribute(i18n.t('projects_add_block'))}" 4088 onclick="window.Roxima.handlePlusClick(event, ${blockIdArg})">+</button> 4089 <button class="block-handle" type="button" data-i18n-title="common_actions" data-i18n-aria-label="common_actions" 4090 title="${escapeAttribute(i18n.t('common_actions'))}" aria-label="${escapeAttribute(i18n.t('common_actions'))}">â®â®</button> 4091 </div> 4092 <div class="block-content-wrapper"> 4093 ${contentHtml} 4094 </div> 4095 ${todoQuickActionHtml} 4096 ${firstFocusActivationHtml} 4097 <button class="mobile-task-action" data-id="${block.id}" type="button" data-i18n-title="projects_task_actions" data-i18n-aria-label="projects_task_actions">â¯</button> 4098 </div> 4099 `; 4100 }).join(''); 4101 } 4102 4103 // --- DRAG & DROP LOGIC --- 4104 let draggedBlockId = null; 4105 4106 function initDragDrop(container) { 4107 // We attach listeners to the container (delegation) for drag events where possible, 4108 // but dragstart/dragover often need to be on specific elements or bubble up. 4109 // Bubbling works fine. 4110 4111 container.addEventListener('dragstart', (e) => { 4112 const blockEl = e.target.closest('.editor-block'); 4113 if (!blockEl) return; 4114 4115 // ONLY Allow drag if handle is the target (or inside handle) 4116 // But HTML5 Drag image behaves better if drag is on the row. 4117 // We check if the mouse started on the handle. 4118 // However, 'dragstart' event doesn't give mouse position or original click target easily if we didn't track mousedown. 4119 // WORKAROUND: We'll assume if the user is dragging the block, they grabbed the handle because standard text selection behaves differently. 4120 // But to be precise like Notion, we should verify the handle. 4121 // Since we can't easily check 'mousedown' target here without global state, 4122 // we will check if the 'handle' is hovered or if we trust the user intent. 4123 // Actually, if 'draggable=true' is on the block, clicking text and dragging selects text usually. 4124 // We need to prevent drag unless handle is grabbed. 4125 // Let's rely on a global 'isHandleGrabbed' flag set by mousedown. 4126 }); 4127 } 4128 4129 // We'll reimplement initDragDrop properly below and call it from render or bindGlobalEvents. 4130 // Instead of complex init, let's just add event listeners to the Editor Container in bindGlobalEvents. 4131 4132 4133 // --- PLUS MENU LOGIC --- 4134 function initPlusMenu(menuEl) { 4135 // Render Options (same as Slash but clickable) 4136 const OPTIONS = [ 4137 ...SLASH_OPTIONS, 4138 { label: 'JSON ð', type: 'export_json', icon: 'ð' } 4139 ]; 4140 4141 menuEl.innerHTML = OPTIONS.map((opt) => ` 4142 <div class="slash-item" data-type="${opt.type}">
4143 <span class="slash-icon">${opt.icon}</span> 4144 <span ${opt.labelKey ? `data-i18n="${opt.labelKey}"` : ''}>${optionLabel(opt)}</span> 4145 </div> 4146 `).join(''); 4147 } 4148 4149 // --- MENU LOGIC (Combined Slash & Plus) --- 4150 4151 function openMenu(targetEl, blockId) { 4152 // Reuse slash-menu container since we know it works 4153 const menuEl = document.getElementById('slash-menu'); 4154 if (!menuEl) return; 4155 4156 // Determine Mode 4157 isPlusMenuMode = targetEl.classList.contains('block-plus') || !!targetEl.closest('.block-plus'); 4158 4159 // Render Options (Full set for menu) 4160 const OPTIONS = [ 4161 ...SLASH_OPTIONS, 4162 // Only add JSON if not already there or if we want it in all menus 4163 { label: 'JSON ð', type: 'export_json', icon: 'ð' } 4164 ]; 4165 4166 menuEl.innerHTML = OPTIONS.map((opt) => ` 4167 <div class="slash-item" data-type="${opt.type}"> 4168 <span class="slash-icon">${opt.icon}</span> 4169 <span ${opt.labelKey ? `data-i18n="${opt.labelKey}"` : ''}>${optionLabel(opt)}</span> 4170 </div> 4171 `).join(''); 4172 4173 // Show 4174 menuEl.style.display = 'flex'; 4175 slashMenuOpen = true; 4176 slashMenuBlockId = blockId; // Use same ID tracker for both 4177 4178 // Position 4179 const rect = targetEl.getBoundingClientRect(); 4180 4181 if (isPlusMenuMode) { 4182 menuEl.style.top = rect.top + 'px'; 4183 menuEl.style.left = (rect.right + 5) + 'px'; 4184 } else { 4185 menuEl.style.top = (rect.bottom + 5) + 'px'; 4186 menuEl.style.left = rect.left + 'px'; 4187 } 4188 } 4189 4190 // Alias for existing slash calls 4191 function openSlashMenu(targetInput, blockId) { 4192 openMenu(targetInput, blockId); 4193 } 4194 4195 function closeSlashMenu() { 4196 slashMenuOpen = false; 4197 slashMenuBlockId = null; 4198 isPlusMenuMode = false; 4199 const menuEl = document.getElementById('slash-menu'); 4200 if (menuEl) menuEl.style.display = 'none'; 4201 } 4202 4203 // Global Handler for Plus Click (Consolidated) 4204 window.Roxima.handlePlusClick = (e, blockId) => { 4205 if (e) { 4206 e.preventDefault(); 4207 e.stopPropagation(); 4208 } 4209 console.log("Direct Plus Click Unified:", blockId); 4210 const btn = e.currentTarget || e.target.closest('.block-plus'); 4211 openMenu(btn, blockId); 4212 }; 4213 4214 // --- CONTEXT MENU (Handle Click) --- 4215 function openContextMenu(targetEl, blockId) { 4216 const menuEl = document.getElementById('slash-menu'); 4217 if (!menuEl) return; 4218 4219 const pageId = selectedPageId; 4220 const block = store.getBlocks(pageId).find(b => b.id === blockId); 4221 4222 // Determine Mode 4223 isPlusMenuMode = true; // Acts like plus menu for behavior 4224 4225 // Options: Delete Block 4226 const OPTIONS = [ 4227 { label: 'Supprimer', labelKey: 'common_delete', type: 'delete_block', icon: 'ðï¸' } 4228 ]; 4229 4230 let prioritySelectorHtml = ''; 4231 if (block && block.type === 'todo') { 4232 prioritySelectorHtml = ` 4233 <div class="context-menu-priority-selector" style="padding: 8px 12px; border-bottom: 1px solid rgba(0,0,0,0.06); display:flex; flex-direction:column; gap:6px;"> 4234 <div style="font-size:0.75rem; font-weight:600; color:#888; text-align:left;">Priorité</div> 4235 <div style="display:flex; gap:8px; align-items:center;"> 4236 <button class="ctx-priority-btn" data-priority="3" title="Haute" style="background:none; border:none; padding:4px; border-radius:4px; cursor:pointer;">ð©</button> 4237 <button class="ctx-priority-btn" data-priority="2" title="Moyenne" style="background:none; border:none; padding:4px; border-radius:4px; cursor:pointer;">ð§</button> 4238 <button class="ctx-priority-btn" data-priority="1" title="Basse" style="background:none; border:none; padding:4px; border-radius:4px; cursor:pointer;">ð¦</button> 4239 <button class="ctx-priority-btn" data-priority="0" title="Aucune" style="background:none; border:none; padding:4px; border-radius:4px; cursor:pointer; opacity:0.5;">ð³ï¸</button> 4240 </div> 4241 </div> 4242 `; 4243 } 4244 4245 menuEl.innerHTML = ` 4246 ${prioritySelectorHtml} 4247 ${OPTIONS.map((opt) => ` 4248 <div class="slash-item" data-type="${opt.type}">
4249 <span class="slash-icon">${opt.icon}</span> 4250 <span ${opt.labelKey ? `data-i18n="${opt.labelKey}"` : ''}>${optionLabel(opt)}</span> 4251 </div> 4252 `).join('')} 4253 `; 4254 4255 // Bind priority buttons 4256 if (block && block.type === 'todo') { 4257 menuEl.querySelectorAll('.ctx-priority-btn').forEach(btn => { 4258 const btnPriority = parseInt(btn.dataset.priority, 10); 4259 if (btnPriority === (block.priority || 0)) { 4260 btn.classList.add('active'); 4261 } 4262 btn.addEventListener('click', (e) => { 4263 e.stopPropagation(); 4264 store.updateBlock(pageId, blockId, { priority: btnPriority }); 4265 render(); 4266 if (ctx.bus) ctx.bus.emit('objectifs:refresh'); 4267 closeSlashMenu(); 4268 }); 4269 }); 4270 } 4271 4272 // Show 4273 menuEl.style.display = 'flex'; 4274 slashMenuOpen = true; 4275 slashMenuBlockId = blockId; 4276 4277 const rect = targetEl.getBoundingClientRect(); 4278 menuEl.style.top = (rect.bottom + 5) + 'px'; 4279 menuEl.style.left = rect.left + 'px'; 4280 } 4281 4282 // Update handlePlusAction to handle delete 4283 function handlePlusAction(type) { 4284 const store = objectifsStore; 4285 4286 if (type === 'delete_block') { 4287 const blockObj = store.getBlocks(selectedPageId).find(b => b.id === slashMenuBlockId); 4288 if (blockObj && blockObj.isNotion && blockObj.notionBlockId) { 4289 deleteBlockFromNotion(selectedPageId, blockObj.notionBlockId); 4290 } 4291 store.deleteBlock(selectedPageId, slashMenuBlockId); 4292 render(); 4293 closeSlashMenu(); 4294 return; 4295 } 4296 4297 if (type === 'export_json') { 4298 const json = JSON.stringify(store.state, null, 2); 4299 console.log(json); 4300 navigator.clipboard.writeText(json).then(() => alert(i18n.t('projects_json_copied'))); 4301 closeSlashMenu(); 4302 return; 4303 } 4304 4305 if (type === 'page_link') { 4306 const title = prompt(i18n.t('projects_new_page_prompt')) || i18n.t('common_untitled'); 4307 const newPage = store.createPage(title, selectedPageId); 4308 store.addBlock(selectedPageId, { type: 'page_link', text: newPage.id }, slashMenuBlockId); 4309 closeSlashMenu(); 4310 render(); 4311 return; 4312 } 4313 4314 // Add new block AFTER the current one (using slashMenuBlockId as the reference) 4315 console.log("Adding block after:", slashMenuBlockId, "Type:", type); 4316 store.addBlock(selectedPageId, { type: type, text: '' }, slashMenuBlockId); 4317 render(); 4318 // Focus usually handled by render loop looking for empty blocks or we can force it? 4319 // Let's rely on render for now. 4320 closeSlashMenu(); 4321 } 4322 4323 // -- UTILS -- 4324 function debounce(func, wait) { 4325 let timeout; 4326 let lastArgs = null; 4327 let lastThis = null; 4328 const debounced = function (...args) { 4329 clearTimeout(timeout); 4330 lastArgs = args; 4331 lastThis = this; 4332 timeout = setTimeout(() => { 4333 timeout = null; 4334 const pendingArgs = lastArgs; 4335 const pendingThis = lastThis; 4336 lastArgs = null; 4337 lastThis = null; 4338 func.apply(pendingThis, pendingArgs); 4339 }, wait); 4340 }; 4341 debounced.cancel = function () { 4342 clearTimeout(timeout); 4343 timeout = null; 4344 lastArgs = null; 4345 lastThis = null; 4346 }; 4347 debounced.flush = function () { 4348 if (!timeout || !lastArgs) return; 4349 clearTimeout(timeout); 4350 timeout = null; 4351 const pendingArgs = lastArgs; 4352 const pendingThis = lastThis; 4353 lastArgs = null; 4354 lastThis = null; 4355 func.apply(pendingThis, pendingArgs); 4356 }; 4357 return debounced; 4358 } 4359 4360 const debouncedUpdateBlock = debounce((store, pageId, id, text, notionData) => { 4361 store.updateBlock(pageId, id, { text, notionData }); 4362 }, 200); 4363 4364 function commitEditableBlockNow(store, pageId, editor) { 4365 if (!editor?.classList?.contains('editable-text')) return null; 4366 4367 const id = editor.dataset.id; 4368 const html = editor.innerHTML; 4369 const text = convertHtmlToMarkdown(html); 4370 const block = (store.getBlocks(pageId) || []) 4371 .find(item => String(item.id) === String(id)); 4372 4373 // A delayed write must never overwrite this authoritative DOM value. 4374 debouncedUpdateBlock.cancel(); 4375 if (!block) return null; 4376 4377 const notionData = { ...(block.notionData || {}) }; 4378 notionData.rich_text = NotionRichText.fromHtml(html); 4379 store.updateBlock(pageId, id, { text, notionData }); 4380 return { id, text }; 4381 } 4382 4383 const debouncedSyncBlockToNotion = debounce((pageId, id) => { 4384 syncBlockToNotion(pageId, id); 4385 }, 1500); 4386 4387 // -- EDITOR LOGIC -- 4388 function initEditor(editorEl, pageId, bus) { 4389 const store = objectifsStore; 4390 4391 editorEl.addEventListener('input', (e) => { 4392 const target = e.target; 4393 if (target.classList.contains('editable-text')) { 4394 const id = target.dataset.id; 4395 const html = target.innerHTML; 4396 const text = convertHtmlToMarkdown(html); 4397 4398 if (id === firstFocusActivationTaskId) { 4399 const activationButton = target.closest('.editor-block')?.querySelector('.first-focus-activation-button'); 4400 if (activationButton && !activationButton.hasAttribute('aria-busy')) { 4401 activationButton.disabled = !text.trim(); 4402 } 4403 } 4404 4405 // SPECIAL: Slash command trigger immediate check (no debounce) 4406 if (text === '/') { 4407 openSlashMenu(target, id); 4408 } else if (slashMenuOpen && !text.startsWith('/')) { 4409 closeSlashMenu(); 4410 } 4411 4412 const blocks = store.getBlocks(pageId); 4413 const block = blocks ? blocks.find(b => b.id === id) : null; 4414 let newNotionData = block ? { ...block.notionData } : {}; 4415 newNotionData.rich_text = NotionRichText.fromHtml(html); 4416 4417 // Normal text update -> Debounced save 4418 debouncedUpdateBlock(store, pageId, id, text, newNotionData); 4419 4420 // Auto-push to Notion after a pause in typing 4421 debouncedSyncBlockToNotion(pageId, id); 4422 } 4423 }); 4424 4425 editorEl.addEventListener('change', (e) => { 4426 if (e.target.classList.contains('todo-check')) { 4427 const id = e.target.dataset.id; 4428 const checked = e.target.checked; 4429 store.updateBlock(pageId, id, { checked }); 4430 // Visual update immediately 4431 const textEl = e.target.closest('.editor-block')?.querySelector('.editable-text'); 4432 if (textEl) { 4433 if (checked) textEl.classList.add('checked-text'); else textEl.classList.remove('checked-text'); 4434 } 4435 if (bus) bus.emit('objectifs:refresh'); 4436 syncBlockToNotion(pageId, id); // Sync checkbox toggle 4437 } 4438 }); 4439 4440 editorEl.addEventListener('focusout', (e) => { 4441 const target = e.target; 4442 if (target.classList.contains('editable-text')) { 4443 const id = target.dataset.id; 4444 if (editorsCommittedBeforeFocusout.has(target)) { 4445 editorsCommittedBeforeFocusout.delete(target); 4446 } else { 4447 // A click can rebuild the editor before the typing debounce fires. 4448 commitEditableBlockNow(store, pageId, target); 4449 } 4450 // Cancel debounced sync and push immediately 4451 debouncedSyncBlockToNotion.cancel(); 4452 // Keep the existing short delay for the remote Notion sync only. 4453 setTimeout(() => { 4454 syncBlockToNotion(pageId, id); 4455 }, 250); 4456 } 4457 }); 4458 4459 editorEl.addEventListener('keydown', (e) => { 4460 const blockControl = e.target.closest('.block-controls button'); 4461 if (blockControl && (e.key === 'Enter' || e.key === ' ')) { 4462 e.preventDefault(); 4463 e.stopPropagation(); 4464 blockControl.click(); 4465 return; 4466 } 4467 4468 handleEditorKeydown_v2(e, store, pageId, editorEl, bus); 4469 }); 4470 4471 // PASTE HANDLER (Smart Paste) 4472 editorEl.addEventListener('paste', (e) => { 4473 const clipboardData = e.clipboardData || window.clipboardData; 4474 if (!clipboardData) return; 4475 4476 const pastedText = clipboardData.getData('text'); 4477 if (!pastedText) return; 4478 4479 // Only intervene if it looks like a list or multiple lines 4480 const lines = pastedText.split(/\r?\n/); 4481 const isListLike = lines.length > 1 || /^\s*(-|\*|\[\s?\]|\[x\])\s/.test(lines[0]); 4482 4483 if (isListLike) { 4484 e.preventDefault(); 4485 4486 const target = e.target; 4487 const currentBlockEl = target.closest('.editor-block'); 4488 if (!currentBlockEl) return; 4489 4490 const currentId = currentBlockEl.dataset.id; 4491 let insertAfterId = currentId; 4492 4493 // Process first line into CURRENT block? 4494 // Only if current block is empty or we want to append? 4495 // Standard behavior: replace selection or append. 4496 // Simpler approach: update current block with first line content, add others after. 4497 4498 // regexes - improved to catch leading bullets on Todos 4499 const regexTodo = /^\s*(-|\*)?\s*\[( |x)\]\s+(.*)/i; 4500 const regexBullet = /^\s*(-|\*)\s+(.*)/; 4501 4502 // Detect indentation context 4503 const rootBlock = store.getBlocks(pageId).find(b => String(b.id) === currentId); 4504 const baseLevel = rootBlock ? (rootBlock.level || 0) : 0; 4505 let firstLineRawIndent = null; 4506
4507 lines.forEach((line, index) => { 4508 let type = 'p'; 4509 let text = line; 4510 let checked = false; 4511 let level = baseLevel; 4512 4513 // Calculate Indentation 4514 const leadingSpace = line.match(/^\s*/)[0]; 4515 const tabCount = (leadingSpace.match(/\t/g) || []).length; 4516 const spaceCount = (leadingSpace.match(/ /g) || []).length; 4517 const currentRawIndent = tabCount + Math.floor(spaceCount / 2); // 2 spaces = 1 level 4518 4519 if (index === 0) { 4520 firstLineRawIndent = currentRawIndent; 4521 } else { 4522 const indentDelta = Math.max(0, currentRawIndent - firstLineRawIndent); 4523 level = baseLevel + indentDelta; 4524 } 4525 4526 // Check Type 4527 const matchTodo = line.match(regexTodo); 4528 if (matchTodo) { 4529 type = 'todo'; 4530 checked = matchTodo[2].toLowerCase() === 'x'; 4531 text = matchTodo[3]; 4532 } else { 4533 const matchBullet = line.match(regexBullet); 4534 if (matchBullet) { 4535 type = 'bulleted_list_item'; 4536 text = matchBullet[2]; 4537 } else { 4538 // Strip leading indent from P text 4539 text = line.replace(/^\s+/, ''); 4540 } 4541 } 4542 4543 if (index === 0) { 4544 store.updateBlock(pageId, insertAfterId, { type, text, checked, level }); 4545 } else { 4546 const newBlock = store.addBlock(pageId, { type, text, checked, level }, insertAfterId); 4547 insertAfterId = newBlock.id; 4548 } 4549 }); 4550 4551 4552 render({ preserveCaret: false }); 4553 // Focus insertion point (last block) 4554 setTimeout(() => focusBlock(insertAfterId, 'end'), 0); 4555 } 4556 }); 4557 } 4558 4559 // --- UTILS --- 4560 function focusBlock(blockId, position = 'end') { 4561 const applyFocus = () => { 4562 const el = container.querySelector(`.editor-block[data-id="${blockId}"] .editable-text`); 4563 if (!el) return false; 4564 el.focus(); 4565 if (typeof position === 'number') { 4566 let remaining = Math.max(0, position); 4567 let caretNode = el; 4568 let caretOffset = el.childNodes.length; 4569 const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT); 4570 let textNode = walker.nextNode(); 4571 4572 while (textNode) { 4573 const textLength = textNode.textContent?.length || 0; 4574 if (remaining <= textLength) {
4575 caretNode = textNode; 4576 caretOffset = remaining; 4577 break; 4578 } 4579 remaining -= textLength; 4580 textNode = walker.nextNode(); 4581 } 4582 4583 const range = document.createRange(); 4584 range.setStart(caretNode, caretOffset); 4585 range.collapse(true); 4586 const sel = window.getSelection(); 4587 sel.removeAllRanges(); 4588 sel.addRange(range); 4589 } else if (position === 'end') { 4590 const range = document.createRange(); 4591 range.selectNodeContents(el); 4592 range.collapse(false); 4593 const sel = window.getSelection(); 4594 sel.removeAllRanges(); 4595 sel.addRange(range); 4596 } else if (position === 'start') { 4597 const range = document.createRange(); 4598 range.selectNodeContents(el); 4599 range.collapse(true); 4600 const sel = window.getSelection(); 4601 sel.removeAllRanges(); 4602 sel.addRange(range); 4603 } 4604 return true; 4605 }; 4606 4607 // render() replaces the editor synchronously, so the destination normally 4608 // exists right away. Keep one animation-frame fallback for async callers. 4609 if (!applyFocus()) requestAnimationFrame(applyFocus); 4610 } 4611 4612 // --- KEYDOWN HANDLER --- 4613 function handleEditorKeydown(e, store, pageId, editorEl) { 4614 const target = e.target; 4615 4616 // 1. Slash Menu Navigation (Priority) 4617 if (slashMenuOpen) { 4618 if (e.key === 'ArrowDown') { 4619 e.preventDefault(); 4620 slashMenuIndex = (slashMenuIndex + 1) % SLASH_OPTIONS.length; 4621 renderSlashMenu(document.getElementById('slash-menu')); 4622 return; 4623 } else if (e.key === 'ArrowUp') { 4624 e.preventDefault(); 4625 slashMenuIndex = (slashMenuIndex - 1 + SLASH_OPTIONS.length) % SLASH_OPTIONS.length; 4626 renderSlashMenu(document.getElementById('slash-menu')); 4627 return; 4628 } else if (e.key === 'Enter') { 4629 e.preventDefault(); 4630 applySlashType(pageId); 4631 return; 4632 } else if (e.key === 'Escape') { 4633 e.preventDefault(); 4634 closeSlashMenu(); 4635 return; 4636 } 4637 } 4638 4639 // 0. SELECTION: Escape to clear 4640 if (e.key === 'Escape') { 4641 if (selection.ids.size > 0) { 4642 selection.clear(); 4643 e.preventDefault(); 4644 return; 4645 } 4646 } 4647 4648 // 0. SELECTION: Shift + Arrows (Expand Selection) 4649 if (e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { 4650 if (selection.ids.size > 0) { 4651 e.preventDefault(); 4652 const allBlocks = Array.from(editorEl.querySelectorAll('.editor-block')).map(el => el.dataset.id); 4653 // Last selected is conceptually the one we moved do last, or anchor if fresh. 4654 // We need to know "active end". For simplicity, let's assume SetRange covers it if we pick target correctly. 4655 // Re-calculating range based on anchor + direction. 4656 // Let's find the "other end" of the current range relative to anchor. 4657 // Or just find the extrema. 4658 4659 // Simple implementation: Just extend from anchor to current focused? No focus might be lost. 4660 // Extend from anchor to neighbor of current "extent". 4661 // Hard to know "extent" without state. 4662 // Let's assume extent is the block visually furthest from anchor in the set. 4663 4664 const sorted = selection.getSortedIds(allBlocks); 4665 const anchorIdx = allBlocks.indexOf(selection.anchorBlockId); 4666 const firstIdx = allBlocks.indexOf(sorted[0]); 4667 const lastIdx = allBlocks.indexOf(sorted[sorted.length - 1]); 4668 4669 let currentExtentIdx = (anchorIdx === firstIdx) ? lastIdx : firstIdx; 4670 if (sorted.length === 1) currentExtentIdx = anchorIdx; 4671 4672 let nextIdx = currentExtentIdx; 4673 if (e.key === 'ArrowUp') nextIdx = Math.max(0, currentExtentIdx - 1); // Move extent up? 4674 // Logic is tricky: If I am below anchor, Up shrinks. If I am above anchor, Up expands. 4675 // Let's do a naive "Move the cursor block" approach if we had a cursor.
4676 // Without cursor, let's just use the "most recent added" logic or simply direction from anchor. 4677 4678 // SIMPLIFIED: Just select neighbor of 'currentBlockEl' if exists, else extend from anchor. 4679 if (currentBlockEl) { 4680 // If we are editing text and press Shift+Up, standard text selection happens. 4681 // We only want block selection if we explicitly started it. 4682 // But user said "Shift + Arrow ... étend la sélection". 4683 } 4684 4685 // Let's rely on standard text selection if inside text. 4686 // ONLY handle this if selection.ids.size > 0 (Block Mode). 4687 4688 // Direction Logic: 4689 if (e.key === 'ArrowUp') nextIdx = Math.max(0, currentExtentIdx - 1); 4690 if (e.key === 'ArrowDown') nextIdx = Math.min(allBlocks.length - 1, currentExtentIdx + 1); 4691 4692 // Wait, if I am at (Anchor=5, Extent=10) and press UP. I was at 10. Go to 9. range 5-9. 4693 // If I am at (Anchor=5, Extent=10) and press DOWN. Go to 11. range 5-11. 4694 // If I am at (Anchor=5, Extent=0) and press UP. Go to -1 (limit 0). 4695 // If I am at (Anchor=5, Extent=0) and press DOWN. Go to 1. 4696 4697 // So we need to know the 'active' edge. Storing 'extent' in state would be better, but 'currentBlockEl' helps if focus is there. 4698 // If focus is lost (clicked gutter), we need state. 4699 // Let's trust that 'anchor' is static. We guess extent. 4700 // Pivot logic: 4701 let targetIdx = currentExtentIdx; 4702 if (currentExtentIdx >= anchorIdx) { 4703 // We are below or at anchor. 4704 if (e.key === 'ArrowDown') targetIdx++; 4705 else targetIdx--; // Up shrinks or flips 4706 } else { 4707 // We are above anchor. 4708 if (e.key === 'ArrowUp') targetIdx--; 4709 else targetIdx++; // Down shrinks or flips 4710 } 4711 4712 targetIdx = Math.max(0, Math.min(allBlocks.length - 1, targetIdx)); 4713 selection.setRange(allBlocks[targetIdx], allBlockIds); 4714 return; 4715 } 4716 } 4717 4718 // 0. SELECTION: Delete / Backspace 4719 if ((e.key === 'Delete' || e.key === 'Backspace') && selection.ids.size > 0) { 4720 e.preventDefault(); 4721 const sortedIds = selection.getSortedIds(Array.from(editorEl.querySelectorAll('.editor-block')).map(el => el.dataset.id)); 4722 4723 const firstId = sortedIds[0]; 4724 const firstEl = editorEl.querySelector(`.editor-block[data-id="${firstId}"]`); 4725 const prevEl = firstEl ? firstEl.previousElementSibling : null; 4726 4727 sortedIds.forEach(id => store.deleteBlock(pageId, id)); 4728 selection.clear(); 4729 render({ preserveCaret: false }); 4730 4731 if (prevEl) { 4732 focusBlock(prevEl.dataset.id, 'end'); 4733 } else if (store.getBlocks(pageId).length === 0) { 4734 store.addBlock(pageId, { type: 'p', text: '' }); 4735 render({ preserveCaret: false }); 4736 const newBlk = editorEl.querySelector('.editor-block'); 4737 if (newBlk) focusBlock(newBlk.dataset.id); 4738 } 4739 return; 4740 } 4741 4742 // 0. SELECTION: Copy (Ctrl+C) 4743 if ((e.ctrlKey || e.metaKey) && e.key === 'c' && selection.ids.size > 0) { 4744 e.preventDefault(); 4745 const sortedIds = selection.getSortedIds(Array.from(editorEl.querySelectorAll('.editor-block')).map(el => el.dataset.id)); 4746 const textToCopy = sortedIds.map(id => { 4747 const block = store.state.blocksByPageId[pageId].find(b => String(b.id) === id); 4748 if (!block) return ''; 4749 if (block.type === 'todo') return block.checked ? `- [x] ${block.text}` : `- [ ] ${block.text}`; 4750 if (block.type === 'bulleted_list_item' || block.type === 'bullet') return `- ${block.text}`; 4751 if (block.type === 'numbered_list_item') return `1. ${block.text}`; 4752 return block.text; 4753 }).join('\n'); 4754 navigator.clipboard.writeText(textToCopy); 4755 return; 4756 } 4757 4758 // 0. PASTE (Generic) 4759 if ((e.ctrlKey || e.metaKey) && e.key === 'v') { 4760 // If selection active, delete it first? 4761 if (selection.ids.size > 0) { 4762 // Simulate delete then paste? 4763 // Implement later. Paste usually handled by 'paste' event, but we want custom block paste. 4764 // Let's rely on browser default for text paste unless we intercepted it. 4765 // If we want to intercept paste to create blocks, we need 'paste' event listener, not keydown. 4766 } 4767 } 4768 4769 4770 if (!target.classList.contains('editable-text')) { 4771 // Allow navigation if we have selection, otherwise return 4772 if (selection.ids.size === 0) return; 4773 // If we have selection and pressed arrow (handled above), or other keys? 4774 // If we are here, we might want to prevent default for some keys? 4775 } 4776 4777 // Ensure we have currentBlockEl for standard operations 4778 const currentBlockEl = target.closest('.editor-block'); 4779 if (!currentBlockEl) return; 4780 4781 const currentId = currentBlockEl.dataset.id; 4782 const currentType = currentBlockEl.dataset.type; 4783 4784 // 2. Ctrl + Enter (Toggle Todo) 4785 if (e.ctrlKey && e.key === 'Enter') { 4786 if (currentType === 'todo') { 4787 e.preventDefault(); 4788 const checkbox = currentBlockEl.querySelector('.todo-check'); 4789 if (checkbox) { 4790 checkbox.checked = !checkbox.checked; 4791 store.updateBlock(pageId, currentId, { checked: checkbox.checked }); 4792 const textEl = currentBlockEl.querySelector('.editable-text'); 4793 if (checkbox.checked) textEl.classList.add('checked-text'); else textEl.classList.remove('checked-text'); 4794 } 4795 } 4796 return; 4797 } 4798 4799 // 3. Enter (New Block) 4800 if (e.key === 'Enter' && !e.shiftKey) { 4801 e.preventDefault(); 4802 4803 // Should next block inherit type? 4804 // Todo -> Todo, Bullet -> Bullet. Others (H1, H2, P) -> P 4805 let nextType = 'p'; 4806 if (currentType === 'todo') nextType = 'todo'; 4807 if (currentType === 'bullet') nextType = 'bullet'; 4808 4809 // Add new block AFTER current 4810 store.addBlock(pageId, { type: nextType, text: '' }, currentId); 4811 render(); 4812 4813 // Focus new block (Wait for render) 4814 setTimeout(() => { 4815 // We need to find the block AFTER the one we were just in. 4816 // Since we re-rendered, DOM refs are lost. Use querySelector by ID to find old one, then nextSibling. 4817 const oldBlockRef = editorEl.querySelector(`.editor-block[data-id="${currentId}"]`); 4818 if (oldBlockRef && oldBlockRef.nextElementSibling) { 4819 const nextInput = oldBlockRef.nextElementSibling.querySelector('.editable-text'); 4820 if (nextInput) nextInput.focus(); 4821 } 4822 }, 0); 4823 return; 4824 } 4825 4826 // 4. Backspace (Delete empty) 4827 if (e.key === 'Backspace') { 4828 if (target.innerText === '') { 4829 e.preventDefault(); 4830 4831 // Find previous block ID to focus BEFORE deleting 4832 const prevBlockEl = currentBlockEl.previousElementSibling; 4833 4834 store.deleteBlock(pageId, currentId); 4835 render(); 4836 4837 if (prevBlockEl) { 4838 // Focus end of previous 4839 setTimeout(() => { 4840 const prevInput = editorEl.querySelector(`.editor-block[data-id="${prevBlockEl.dataset.id}"] .editable-text`); 4841 if (prevInput) { 4842 prevInput.focus(); 4843 // Move cursor to end 4844 const range = document.createRange(); 4845 range.selectNodeContents(prevInput); 4846 range.collapse(false); 4847 const sel = window.getSelection(); 4848 sel.removeAllRanges(); 4849 sel.addRange(range); 4850 } 4851 }, 0); 4852 } else { 4853 // If it was the first block, maybe recreate a default P if list empty? 4854 // Checked in render(): checks blocks.length. If 0, shows empty state. 4855 } 4856 return; 4857 } 4858 } 4859 4860 // 5. Arrow Navigation 4861 if (e.key === 'ArrowUp') { 4862 const prevBlock = currentBlockEl.previousElementSibling; 4863 if (prevBlock) { 4864 e.preventDefault(); 4865 const input = prevBlock.querySelector('.editable-text'); 4866 if (input) input.focus(); // Simple focus, keeping cursor pos is hard across blocks 4867 } 4868 } else if (e.key === 'ArrowDown') { 4869 const nextBlock = currentBlockEl.nextElementSibling; 4870 if (nextBlock) { 4871 e.preventDefault(); 4872 const input = nextBlock.querySelector('.editable-text'); 4873 if (input) input.focus(); 4874 } 4875 } 4876 } 4877 4878 // --- KEYDOWN HANDLER V2 (Refined) --- 4879 function handleEditorKeydown_v2(e, store, pageId, editorEl, bus) { 4880 const target = e.target; 4881 4882 // 1. Slash Menu Navigation 4883 if (slashMenuOpen) { 4884 if (e.key === 'ArrowUp') { 4885 e.preventDefault(); 4886 slashMenuIndex = Math.max(0, slashMenuIndex - 1); 4887 renderSlashMenu(document.getElementById('slash-menu')); 4888 return; 4889 } 4890 if (e.key === 'ArrowDown') { 4891 e.preventDefault(); 4892 slashMenuIndex = Math.min(SLASH_OPTIONS.length - 1, slashMenuIndex + 1); // Fixed limit 4893 renderSlashMenu(document.getElementById('slash-menu')); 4894 return; 4895 } 4896 if (e.key === 'Enter') { 4897 e.preventDefault(); 4898 applySlashType(pageId); 4899 return; 4900 } 4901 if (e.key === 'Escape') { 4902 closeSlashMenu(); 4903 return; // Don't propagate Escape clearing selection if menu was open 4904 } 4905 } 4906 4907 if (e.key === 'Escape') { 4908 if (selection.ids.size > 0) { 4909 e.preventDefault(); 4910 selection.clear(); 4911 return; 4912 } 4913 } 4914 4915 if (selection.ids.size > 0) { 4916 const primaryModifier = e.ctrlKey || e.metaKey;
4917 const key = e.key.toLowerCase(); 4918 4919 // Notion-style block movement: keep Ctrl/Cmd + Shift held and use 4920 // the vertical arrows to move the complete selection as one unit. 4921 if (primaryModifier && e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { 4922 e.preventDefault(); 4923 moveCurrentBlockSelection(e.key === 'ArrowUp' ? 'up' : 'down'); 4924 return; 4925 } 4926 4927 if (primaryModifier && key === 'c') { 4928 e.preventDefault(); 4929 copyCurrentBlockSelection(); 4930 return; 4931 } 4932 if (primaryModifier && key === 'x') { 4933 e.preventDefault(); 4934 if (copyCurrentBlockSelection({ showToast: false })) deleteCurrentBlockSelection(); 4935 return; 4936 } 4937 if (primaryModifier && key === 'd') { 4938 e.preventDefault(); 4939 insertProjectClipboard({ duplicate: true }); 4940 return; 4941 } 4942 if (primaryModifier && key === 'v' && projectBlockClipboard.length) { 4943 e.preventDefault(); 4944 insertProjectClipboard(); 4945 return; 4946 } 4947 if (e.key === 'Delete' || e.key === 'Backspace') { 4948 e.preventDefault(); 4949 deleteCurrentBlockSelection(); 4950 return; 4951 } 4952 if (e.shiftKey && (e.key === 'ArrowUp' || e.key === 'ArrowDown')) { 4953 e.preventDefault(); 4954 const allBlockIds = getEditorBlockIds(editorEl); 4955 const currentExtent = selection.extentBlockId || selection.anchorBlockId; 4956 const currentIndex = Math.max(0, allBlockIds.indexOf(currentExtent)); 4957 const direction = e.key === 'ArrowUp' ? -1 : 1; 4958 const targetIndex = Math.max(0, Math.min(allBlockIds.length - 1, currentIndex + direction)); 4959 selection.setRange(allBlockIds[targetIndex], allBlockIds); 4960 return; 4961 } 4962 } 4963 4964 if (!target.classList.contains('editable-text')) return; 4965 const currentBlockEl = target.closest('.editor-block'); 4966 const currentId = currentBlockEl.dataset.id; 4967 const currentType = currentBlockEl.dataset.type; 4968 4969 // 2. Ctrl + Enter (Toggle Todo) 4970 if (e.ctrlKey && e.key === 'Enter') { 4971 if (currentType === 'todo') { 4972 e.preventDefault(); 4973 const checkbox = currentBlockEl.querySelector('.todo-check'); 4974 if (checkbox) { 4975 checkbox.checked = !checkbox.checked; 4976 store.updateBlock(pageId, currentId, { checked: checkbox.checked }); 4977 const textEl = currentBlockEl.querySelector('.editable-text'); 4978 if (checkbox.checked) textEl.classList.add('checked-text'); else textEl.classList.remove('checked-text'); 4979 if (bus) bus.emit('objectifs:refresh'); 4980 } 4981 } 4982 return; 4983 } 4984 4985 // TAB: Indent / Outdent 4986 if (e.key === 'Tab') { 4987 e.preventDefault(); 4988 debouncedUpdateBlock.flush(); 4989 const blocks = store.getBlocks(pageId); 4990 const idx = blocks.findIndex(b => String(b.id) === currentId); 4991 const item = blocks[idx]; 4992 if (!item) return; 4993 4994 if (e.shiftKey) { 4995 // Outdent 4996 if ((item.level || 0) > 0) { 4997 store.updateBlock(pageId, currentId, { level: (item.level || 0) - 1 }); 4998 render({ preserveCaret: false }); 4999 focusBlock(currentId); 5000 } 5001 } else { 5002 // Indent 5003 const prevItem = idx > 0 ? blocks[idx - 1] : null; 5004 const maxLevel = prevItem ? (prevItem.level || 0) + 1 : 0; 5005 const nextLevel = (item.level || 0) + 1; 5006 5007 if (nextLevel <= maxLevel) { 5008 store.updateBlock(pageId, currentId, { level: nextLevel }); 5009 render({ preserveCaret: false }); 5010 focusBlock(currentId); 5011 } 5012 } 5013 return; 5014 } 5015 5016 // Space key: Markdown shortcuts (e.g. "- " -> bulleted list, "1. " -> numbered list, "[] " -> todo, etc.) 5017 if (e.key === ' ') { 5018 const text = target.innerText; 5019 const sel = window.getSelection(); 5020 if (sel.rangeCount > 0) { 5021 const range = sel.getRangeAt(0); 5022 const preCaretRange = range.cloneRange(); 5023 preCaretRange.selectNodeContents(target); 5024 preCaretRange.setEnd(range.endContainer, range.endOffset); 5025 const caretAt = preCaretRange.toString().length; 5026 5027 if (caretAt === text.length) { 5028 let newType = null; 5029 if (currentType === 'p') { 5030 if (text === '-' || text === '*') newType = 'bulleted_list_item'; 5031 else if (text === '1.') newType = 'numbered_list_item'; 5032 else if (text === '[]' || text === '- [ ]' || text === '[ ]') newType = 'todo'; 5033 else if (text === '#') newType = 'h1'; 5034 else if (text === '##') newType = 'h2'; 5035 else if (text === '>') newType = 'quote'; 5036 } 5037 5038 if (newType) { 5039 e.preventDefault(); 5040 store.updateBlock(pageId, currentId, { type: newType, text: '' }); 5041 render({ preserveCaret: false }); 5042 focusBlock(currentId, 'start'); 5043 return; 5044 } 5045 } 5046 } 5047 } 5048 5049 // 3. Enter (New Block / Split Block) 5050 if (e.key === 'Enter' && !e.shiftKey) { 5051 e.preventDefault(); 5052 // Read the live content directly: creating and rendering the next block 5053 // must never outrun the local save of the current one. 5054 commitEditableBlockNow(store, pageId, target); 5055 editorsCommittedBeforeFocusout.add(target); 5056 5057 // A. Empty Item -> Cancel List (Todo/Bullet/Number -> P) 5058 if (target.innerText.trim() === '' && (currentType === 'todo' || currentType === 'bulleted_list_item' || currentType === 'numbered_list_item')) { 5059 store.updateBlock(pageId, currentId, { type: 'p' }); 5060 render({ preserveCaret: false }); 5061 focusBlock(currentId, 'start'); 5062 return; 5063 } 5064 5065 // B. Split Text Logic 5066 let textBefore = target.innerText; 5067 let textAfter = ''; 5068 5069 const sel = window.getSelection(); 5070 if (sel.rangeCount > 0) { 5071 const range = sel.getRangeAt(0); 5072 const preCaretRange = range.cloneRange(); 5073 preCaretRange.selectNodeContents(target); 5074 preCaretRange.setEnd(range.endContainer, range.endOffset); 5075 const caretAt = preCaretRange.toString().length; 5076 5077 textBefore = target.innerText.substring(0, caretAt); 5078 textAfter = target.innerText.substring(caretAt); 5079 } 5080 5081 // Update Current Block (Before split) 5082 if (textAfter !== '') { 5083 store.updateBlock(pageId, currentId, { text: textBefore }); 5084 } 5085 5086 // Create New Block 5087 let nextType = 'p'; 5088 if (currentType === 'todo') nextType = 'todo'; 5089 if (currentType === 'bulleted_list_item' || currentType === 'bullet') nextType = 'bulleted_list_item'; 5090 if (currentType === 'numbered_list_item') nextType = 'numbered_list_item'; 5091 5092 // Inherit Level 5093 const currentBlockFn = store.getBlocks(pageId).find(b => String(b.id) === currentId); 5094 const currentLevel = currentBlockFn ? (currentBlockFn.level || 0) : 0; 5095 5096 const newBlock = store.addBlock(pageId, { type: nextType, text: textAfter, level: currentLevel }, currentId); 5097 5098 render({ preserveCaret: false }); 5099 focusBlock(newBlock.id, 'start'); 5100 return; 5101 } 5102 5103 // 4. Backspace 5104 if (e.key === 'Backspace') { 5105 const sel = window.getSelection(); 5106 if (sel.rangeCount > 0) { 5107 const range = sel.getRangeAt(0); 5108 const preCaretRange = range.cloneRange(); 5109 preCaretRange.selectNodeContents(target); 5110 preCaretRange.setEnd(range.endContainer, range.endOffset); 5111 const caretAt = preCaretRange.toString().length; 5112 5113 // If caret is at the beginning of the block 5114 if (caretAt === 0) { 5115 // A. Convert list item / header / quote / callout back to paragraph 'p' first 5116 if (currentType !== 'p') { 5117 e.preventDefault(); 5118 store.updateBlock(pageId, currentId, { type: 'p' }); 5119 render({ preserveCaret: false }); 5120 focusBlock(currentId, 'start'); 5121 return; 5122 } 5123
5124 // B. Delete empty block if it is a paragraph 5125 if (target.innerText.trim() === '') { 5126 e.preventDefault(); 5127 const allBlocks = editorEl.querySelectorAll('.editor-block'); 5128 if (allBlocks.length === 1) return; // Keep at least one block 5129 5130 const prevBlockEl = currentBlockEl.previousElementSibling; 5131 const blockObj = store.getBlocks(pageId).find(b => String(b.id) === currentId); 5132 if (blockObj && blockObj.isNotion && blockObj.notionBlockId) { 5133 deleteBlockFromNotion(pageId, blockObj.notionBlockId); 5134 } 5135 5136 store.deleteBlock(pageId, currentId); 5137 render({ preserveCaret: false }); 5138 if (prevBlockEl) { 5139 focusBlock(prevBlockEl.dataset.id, 'end'); 5140 } 5141 return; 5142 } 5143 } 5144 } 5145 } 5146 5147 // 5. Arrow Navigation 5148 if (e.key === 'ArrowUp') { 5149 const prevBlock = currentBlockEl.previousElementSibling; 5150 if (prevBlock) { 5151 e.preventDefault(); 5152 focusBlock(prevBlock.dataset.id, 'end'); 5153 } 5154 } else if (e.key === 'ArrowDown') { 5155 const nextBlock = currentBlockEl.nextElementSibling; 5156 if (nextBlock) { 5157 e.preventDefault(); 5158 focusBlock(nextBlock.dataset.id, 'start'); 5159 } 5160 } 5161 } 5162 5163 // --- HELPERS FOR INIT (Since openMenu handles the real work) --- 5164 function initSlashMenu(menuEl) { 5165 menuEl.style.display = 'none'; 5166 menuEl.innerHTML = ''; 5167 } 5168
5169 function renderSlashMenu(menuEl) { 5170 // Only for arrow key nav highlight updates 5171 const activeItem = menuEl.children[slashMenuIndex]; 5172 if (activeItem) { 5173 Array.from(menuEl.children).forEach(c => c.classList.remove('active')); 5174 activeItem.classList.add('active'); 5175 } 5176 } 5177 5178 function applySlashType(pageId, typeOverride = null) { 5179 const store = objectifsStore; 5180 const OPTIONS = [ 5181 ...SLASH_OPTIONS, 5182 { label: 'JSON ð', type: 'export_json', icon: 'ð' } 5183 ]; 5184 const type = typeOverride || OPTIONS[slashMenuIndex].type; 5185 5186 if (type === 'export_json') { 5187 const json = JSON.stringify(store.state, null, 2); 5188 console.log(json); 5189 navigator.clipboard.writeText(json).then(() => alert(i18n.t('projects_json_copied'))); 5190 closeSlashMenu(); // Close menu after action 5191 return; 5192 } 5193 5194 if (type === 'page_link') { 5195 const title = prompt(i18n.t('projects_new_page_prompt')) || i18n.t('common_untitled'); 5196 const newPage = store.createPage(title, pageId); 5197 store.updateBlock(pageId, slashMenuBlockId, { type: 'page_link', text: newPage.id }); 5198 closeSlashMenu(); 5199 render(); 5200 return; 5201 } 5202 5203 // Update current block to new type and clear text 5204 store.updateBlock(pageId, slashMenuBlockId, { type: type, text: '' }); 5205 render(); // Force UI update 5206 5207 closeSlashMenu(); 5208 // Focus back 5209 setTimeout(() => { 5210 const input = document.querySelector(`[data-id="${slashMenuBlockId}"] .editable-text`); 5211 if (input) input.focus(); 5212 }, 50); 5213 } 5214 5215 // -- SHARED BINDINGS -- 5216 function bindGlobalEvents(container, store, treeRoot) { 5217 // Clear block selection only when the user really leaves the editor or its action bar. 5218 if (container.__roximaProjectSelectionOutsideClick) { 5219 container.removeEventListener('click', container.__roximaProjectSelectionOutsideClick, true); 5220 } 5221 container.__roximaProjectSelectionOutsideClick = (e) => { 5222 if (selection.ids.size === 0) return; 5223 const remainsInSelectionUi = e.target.closest('.editor-block, .selection-action-bar, .modal-container, .action-sheet'); 5224 if (!remainsInSelectionUi) selection.clear(); 5225 }; 5226 container.addEventListener('click', container.__roximaProjectSelectionOutsideClick, true); 5227 5228 // Selection Action Bar Buttons 5229 const selectionBar = container.querySelector('#project-selection-bar'); 5230 if (selectionBar) { 5231 const btnCopy = selectionBar.querySelector('.btn-copy-legacy-disabled'); 5232 if (btnCopy) { 5233 btnCopy.addEventListener('click', (e) => { 5234 e.stopPropagation(); 5235 const pageId = selectedPageId; 5236 if (!pageId) return; 5237 const sortedIds = selection.getSortedIds(Array.from(container.querySelectorAll('.editor-block')).map(el => el.dataset.id)); 5238 const textToCopy = sortedIds.map(id => { 5239 const block = store.state.blocksByPageId[pageId].find(b => String(b.id) === id); 5240 if (!block) return ''; 5241 if (block.type === 'todo') return block.checked ? `- [x] ${block.text}` : `- [ ] ${block.text}`; 5242 if (block.type === 'bulleted_list_item' || block.type === 'bullet') return `- ${block.text}`; 5243 if (block.type === 'numbered_list_item') return `1. ${block.text}`; 5244 return block.text; 5245 }).join('\n'); 5246 navigator.clipboard.writeText(textToCopy); 5247 window.Roxima.showToast?.(i18n.t('projects_toast_copied') || 'Blocs copiés !'); 5248 selection.clear(); 5249 }); 5250 } 5251 5252 const btnDelete = selectionBar.querySelector('.btn-delete-legacy-disabled'); 5253 if (btnDelete) { 5254 btnDelete.addEventListener('click', (e) => { 5255 e.stopPropagation(); 5256 const pageId = selectedPageId; 5257 if (!pageId) return; 5258 const sortedIds = selection.getSortedIds(Array.from(container.querySelectorAll('.editor-block')).map(el => el.dataset.id)); 5259 sortedIds.forEach(id => { 5260 const blockObj = store.getBlocks(pageId).find(b => String(b.id) === id); 5261 if (blockObj && blockObj.isNotion && blockObj.notionBlockId) { 5262 deleteBlockFromNotion(pageId, blockObj.notionBlockId); 5263 } 5264 store.deleteBlock(pageId, id); 5265 }); 5266 selection.clear(); 5267 render(); 5268 }); 5269 } 5270 5271 selectionBar.querySelector('.btn-copy')?.addEventListener('click', (e) => { 5272 e.stopPropagation(); 5273 copyCurrentBlockSelection(); 5274 }); 5275 selectionBar.querySelector('.btn-paste')?.addEventListener('click', (e) => { 5276 e.stopPropagation(); 5277 insertProjectClipboard(); 5278 }); 5279 selectionBar.querySelector('.btn-duplicate')?.addEventListener('click', (e) => { 5280 e.stopPropagation(); 5281 if (copyCurrentBlockSelection({ showToast: false })) insertProjectClipboard(); 5282 }); 5283 selectionBar.querySelector('.btn-delete')?.addEventListener('click', (e) => { 5284 e.stopPropagation(); 5285 deleteCurrentBlockSelection(); 5286 }); 5287 5288 const btnCancel = selectionBar.querySelector('.btn-cancel'); 5289 if (btnCancel) { 5290 btnCancel.addEventListener('click', (e) => { 5291 e.stopPropagation(); 5292 selection.clear(); 5293 }); 5294 } 5295 } 5296 5297 // Zoom 5298 const btnZoom = container.querySelector('#btn-zoom'); 5299 if (btnZoom) btnZoom.addEventListener('click', () => ctx.bus.emit('ui:zoom', { target: container })); 5300 5301 // Settings 5302 const btnSettings = container.querySelector('#btn-settings'); 5303 if (btnSettings) { 5304 btnSettings.type = 'button'; 5305 btnSettings.dataset.i18nTitle = 'projects_settings';
5306 btnSettings.dataset.i18nAriaLabel = 'projects_settings'; 5307 btnSettings.title = i18n.t('projects_settings'); 5308 btnSettings.setAttribute('aria-label', i18n.t('projects_settings')); 5309 btnSettings.querySelector('svg')?.setAttribute('aria-hidden', 'true'); 5310 btnSettings.querySelector('svg')?.setAttribute('focusable', 'false'); 5311 btnSettings.addEventListener('click', () => ctx.bus.emit('ui:modal:open', { 5312 titleKey: 'projects_settings_title', 5313 content: '<p data-i18n="projects_settings_body">...</p>' 5314 })); 5315 } 5316 5317 // Add Page (Sidebar Header) - Also delegated in handleContainerClick? 5318 // Let's rely on handleContainerClick for consistency, or keep here if it works. 5319 // It's in .obj-actions which is re-rendered. So listener here is fine. 5320 const btnAdd = container.querySelector('#btn-add-page'); 5321 if (btnAdd) btnAdd.addEventListener('click', (e) => { 5322 e.stopPropagation(); // Prevent container click 5323 const newPage = store.createPage(i18n.t('common_untitled')); 5324 setSelectedPageId(newPage.id); 5325 render(); 5326 }); 5327 5328 // NOTE: Sidebar Tree Listeners (Delete/Add Sub-page/Select) are handled by 5329 // handleContainerClick via event delegation. 5330 // This ensures they work even after re-renders without re-binding complex logic. 5331 } 5332 5333 // --- EMOJI PICKER --- 5334 const EMOJIS = [ 5335 'ð', 'ð ', 'â¡', 'ð¯', 'ð ', 'â ', 'ð¥', 'ð¡', 'ð¨', 'ð§ ', 'ð', 'â', 'ð', 'âï¸', 'ð§±', 'â¤ï¸', 'ð', 'ð', 'ð', 'ð', 'ð»', 'ð¤', 'ð', 'ð', 'ð¼', 5336 'ð»', 'â¨ï¸', 'ð¥ï¸', 'ð±', 'ð', 'ð', 'ð', 'ð', 'ð', 'ð', 'ðï¸', 'ð', 'ð', 5337 'ð®', 'ð§©', 'ð²', 'ð ï¸', 'âï¸', 'ð§', 'â³', 'â±ï¸', 'â°', 'ð', 'ð¸', 'ð°', 5338 'ð', 'ð', 'ð', 'ð¤', 'ð ', 'ð', 'ð´', 'ð¤¯', 'ð¥³', 'ð', 5339 'ð²', 'ð¿', 'ð', 'ð', 'âï¸', 'âï¸', 'ð', 'â°ï¸', 'ðï¸', 5340 'ð§', 'ðµ', 'ð®', 'ð¬', 'ð¸', 'âï¸', 'ð', 'ð´', 'ð', 'â', 'ð', 'ðº' 5341 ]; 5342 5343 function showEmojiPicker(x, y, onSelect) { 5344 const root = document.getElementById('slash-menu'); // Reuse or create new? Reuse is fine just clear 5345 if (!root) return; 5346 5347 root.innerHTML = ` 5348 <div class="hide-scrollbar" style="display:grid; grid-template-columns:repeat(5, 1fr); gap:4px; padding-bottom:6px; max-height:200px; overflow-y:auto;"> 5349 ${EMOJIS.map(e => `<div class="emoji-item" style="cursor:pointer; padding:6px; font-size:1.2rem; text-align:center; border-radius:4px;">${e}</div>`).join('')} 5350 </div> 5351 <button id="remove-icon-btn" style="width:100%; margin-top:6px; padding:4px; border:none; background:#f3f4f6; cursor:pointer; border-radius:4px; font-size:0.8rem;" data-i18n="common_delete">${i18n.t('common_delete')}</button> 5352 `; 5353 5354 root.style.display = 'block'; 5355 root.style.left = x + 'px'; 5356 root.style.top = y + 'px'; 5357 5358 // Bind clicks 5359 root.querySelectorAll('.emoji-item').forEach(el => { 5360 el.addEventListener('click', (e) => { 5361 e.stopPropagation(); 5362 onSelect(el.innerText); 5363 root.style.display = 'none'; 5364 }); 5365 el.addEventListener('mouseover', () => el.style.background = '#f3f4f6'); 5366 el.addEventListener('mouseout', () => el.style.background = 'transparent'); 5367 }); 5368 5369 root.querySelector('#remove-icon-btn').addEventListener('click', (e) => { 5370 e.stopPropagation(); 5371 onSelect(null); 5372 root.style.display = 'none'; 5373 }); 5374 5375 // Click outside to close (Global listener handles this usually via slashMenuOpen flag if reused, but here generic) 5376 const close = (e) => { 5377 if (!root.contains(e.target)) { 5378 root.style.display = 'none'; 5379 document.removeEventListener('click', close); 5380 } 5381 }; 5382 setTimeout(() => document.addEventListener('click', close), 100); 5383 } 5384 5385 // --- UPDATED RENDER TREE (Icons) --- 5386 function renderTree(rootEl, allPages, parentId) { 5387 const children = allPages.filter(p => p.parentId === parentId).sort((a, b) => (a.position ?? 0) - (b.position ?? 0));
5388 children.forEach(page => { 5389 const div = document.createElement('div'); 5390 const pageId = String(page.id ?? ''); 5391 const isActive = selectedPageIds.has(pageId); 5392 const icon = page.icon || 'ð'; 5393 5394 const hasChildren = allPages.some(p => String(p.parentId) === pageId); 5395 const isExpanded = expandedPages.has(pageId); 5396 5397 div.innerHTML = ` 5398 <div class="page-item ${isActive ? 'active' : ''}" data-id="${escapeAttribute(pageId)}"> 5399 ${hasChildren ? ` 5400 <button class="toggle-folder-btn" data-id="${escapeAttribute(pageId)}" type="button" style="background:none; border:none; cursor:pointer; padding:0 4px; color:var(--text-muted); display:flex; align-items:center; justify-content:center; width:24px; height:24px; margin-right:2px; flex-shrink:0;"> 5401 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="transform: ${isExpanded ? 'rotate(90deg)' : 'rotate(0deg)'}; transition: transform 0.2s;"> 5402 <polyline points="9 18 15 12 9 6"></polyline> 5403 </svg> 5404 </button> 5405 ` : `<div style="width:24px; height:24px; margin-right:2px; flex-shrink:0;"></div>`} 5406 <div class="page-item-content"> 5407 <span class="page-icon">${escapeHtml(icon)}</span> 5408 <span class="page-title-tree">${escapeHtml(page.title || i18n.t('common_untitled'))}</span> 5409 </div> 5410 <div class="page-item-actions"> 5411 <button class="mini-btn action-add-sub" data-id="${escapeAttribute(pageId)}" type="button" data-i18n-title="projects_add_subpage" data-i18n-aria-label="projects_add_subpage" title="${escapeAttribute(i18n.t('projects_add_subpage'))}" aria-label="${escapeAttribute(i18n.t('projects_add_subpage'))}">+</button> 5412 <button class="mini-btn action-del" data-id="${escapeAttribute(pageId)}" type="button" data-i18n-title="common_delete" data-i18n-aria-label="common_delete" title="${escapeAttribute(i18n.t('common_delete'))}" aria-label="${escapeAttribute(i18n.t('common_delete'))}">Ã</button> 5413 </div> 5414 </div> 5415 <div class="page-children" style="display: ${isExpanded ? 'flex' : 'none'};"></div> 5416 `; 5417 rootEl.appendChild(div); 5418 // Recursive 5419 renderTree(div.querySelector('.page-children'), allPages, page.id); 5420 }); 5421 } 5422 5423 // --- RE-BIND Slash Options to include Page Link --- 5424 // Note: We need to modify slashMenu logic to handle 'page_link' specifically 5425 5426 // Initial Render 5427 render(); 5428 syncPlanningTimer(); 5429 5430 // Charger les connexions depuis le compte Roxima. Le cache local ne 5431 // contient que leurs métadonnées et permet un rendu immédiat hors ligne. 5432 if (ctx.currentUser) { 5433 refreshNotionConnections() 5434 .then((connections) => { 5435 if (window.Roxima?.notionOAuthReturn === true) { 5436 const latestConnection = [...connections] 5437 .filter(connection => !isDemoNotionWorkspace(connection)) 5438 .sort((a, b) => (Date.parse(b.updatedAt || b.connectedAt) || 0) - (Date.parse(a.updatedAt || a.connectedAt) || 0))[0]; 5439 if (latestConnection) { 5440 localStorage.setItem('roxima_notion_active_workspace_id', latestConnection.id); 5441 } 5442 } 5443 const activeWorkspace = getActiveNotionWorkspace(); 5444 render(); 5445 if (activeWorkspace && !isDemoNotionWorkspace(activeWorkspace)) { 5446 syncNotion(activeWorkspace, { 5447 revealImportedPage: window.Roxima?.notionOAuthReturn === true 5448 }); 5449 setupNotionSyncInterval(activeWorkspace); 5450 } 5451 }) 5452 .catch(error => { 5453 console.warn('Impossible de charger les connexions Notion :', error); 5454 if (window.Roxima?.notionOAuthReturn === true) { 5455 window.Roxima.notionOAuthReturn = false;
5456 ctx.bus.emit('notion:sync:error', { message: error?.message || '' }); 5457 } 5458 const cachedWorkspace = getActiveNotionWorkspace(); 5459 if (cachedWorkspace && !isDemoNotionWorkspace(cachedWorkspace)) { 5460 setupNotionSyncInterval(cachedWorkspace); 5461 } 5462 }); 5463 } 5464 5465 ctx.bus?.on('auth:state', ({ user } = {}) => { 5466 if (!user) return; 5467 refreshNotionConnections() 5468 .then(() => { 5469 const activeWorkspace = getActiveNotionWorkspace(); 5470 render(); 5471 if (activeWorkspace && !isDemoNotionWorkspace(activeWorkspace)) { 5472 syncNotion(activeWorkspace); 5473 setupNotionSyncInterval(activeWorkspace); 5474 } 5475 }) 5476 .catch(error => console.warn('Impossible d actualiser les connexions Notion :', error)); 5477 }); 5478}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.