diff --git a/AGENTS.md b/AGENTS.md index e969c94..e2de5b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,12 @@ Regras e atalhos para quem (agente ou humano) mexe neste repositório. opções e a recomendada primeiro), não como texto solto. - **Commits, títulos/corpos de PR e nomes de branch em inglês.** Identificadores no código em inglês. Copy de UI em **pt-BR**. +- **Copy de UI nasce em i18n**: todo texto de interface (label, `aria-label`, + `title`, toast, placeholder) entra em `messages/pt-BR.json` **e** + `messages/en.json` no mesmo commit. Nada de string solta em `.svelte` — o + guard `src/lib/i18n/hardcoded.test.ts` roda no CI. Escreva pensando em + tradução: sem concatenar frases, use placeholders da própria mensagem. Ver + `docs/I18N.md`. - Mensagem de commit: convencional (`feat:`, `fix:`, `docs:`), corpo em bullets explicando o porquê — não descreva só o diff. diff --git a/docs/ANIM-NOTE-BUBBLE.md b/docs/ANIM-NOTE-BUBBLE.md index 5bb953b..5ad8ef3 100644 --- a/docs/ANIM-NOTE-BUBBLE.md +++ b/docs/ANIM-NOTE-BUBBLE.md @@ -151,3 +151,25 @@ Framer Motion / Rive / After Effects e nos mandar o arquivo — a gente adapta o desliza para baixo; o morph é só na entrada. Respeitar `prefers-reduced-motion`. - A bolha da nota (preview do bloco a partir do chip `tempo`) continua passiva; o contrato de âncoras e `z-index` da §3 segue valendo. + +--- + +## 8. Ajustes de outubro/2026 (pós-S15) + +- **Fixar não reanima.** Quando a bolha do planejador já está aberta, segurar + para fixar **não** repete a entrada (spring/cauda/stagger nem som): ela + reposiciona na hora e confirma com micro-pulso de escala (`1 → 1.03 → 1`, + ~160 ms) + háptica. A entrada cheia só acontece na primeira abertura. +- **Material mais opaco.** Fundo ~92% de opacidade (antes 72%) mantendo blur e + tint da categoria; borda e realce interno levemente mais fortes. +- **Chip `tempo` é um chip só.** O `code` que embrulha a referência perde + fundo/padding quando contém `.tp-ref`; o texto do chip é + `user-select: none` (sem callout no iOS) para o **hold de 350 ms** abrir a + bolha do bloco sem selecionar texto. Tap continua alternando a bolha. +- **Blocos colados da mesma categoria** aparecem como uma entrada só no bubble + (`13:00–13:30` + `13:30–14:00` → `13:00–14:00`). +- **Insert de referência não abre teclado**: o texto entra no último caret + conhecido (ou no fim do corpo) sem `focus()` no textarea. +- **Janela de horas** do planejador é configurável no sheet de range + (preferência local, não sincroniza); o componente de calendário continua só + com datas. diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 3149d27..c9f5bdf 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -185,8 +185,10 @@ Ações em módulos sensíveis (notas, finanças) aparecem **bloqueadas** ("A IA Selecionável nas configurações. Sempre dentro de um tile discreto "Invocar" e no drawer. +- **Atlas (padrão):** ainda sem arte; por enquanto aparece só o nome "Atlas". - **Gato:** corpo grafite, olhos na cor de `--acc`. Estados: dormindo (respira, "z"), acordado (pisca, orelhas mexem), pensando (três pontos), feliz (olhos em arco). Os olhos seguem o ponteiro. Miado sintetizado ao invocar. - **Ghost:** casca angular grafite com pontas e olho em lime. **Estado normal: placas encaixadas** (esfera quase toda coberta, só frestas escuras e o olho). Animações: varredura (radar), vista explodida, flutuar, pensar, celebrar, dormir. +- Trocar o mascote exige **Atlas Pro** no mobile; o padrão Atlas é livre. No desktop o plano é sempre Pro. - Reações ligadas a eventos: aplicar sugestão = feliz + confete; erro/espera = pensando. --- diff --git a/messages/en.json b/messages/en.json index dc470ae..95e3fc8 100644 --- a/messages/en.json +++ b/messages/en.json @@ -26,6 +26,12 @@ "settings_first_day_hint": "Affects weekly habits, goals and calendars.", "settings_day_mon": "Mon", "settings_day_sun": "Sun", + "settings_mascot_title": "Mascot", + "settings_mascot_hint": "Who shows up in Atlas. Soon.", + "settings_mascot_pro": "Changing requires Atlas Pro", + "settings_mascot_atlas": "Atlas", + "settings_mascot_cat": "Cat", + "settings_mascot_ghost": "Ghost", "settings_diag_title": "Notification diagnostics", "settings_diag_permission": "Permission: {state}", "settings_diag_granted": "granted", @@ -328,6 +334,10 @@ "planner_range_apply": "Apply", "planner_range_hint": "Tap the first and the last day.", "planner_range_max": "max. {days} days", + "time_window_title": "Time window", + "time_window_hint": "Hours shown in the grid", + "time_window_from": "Start", + "time_window_to": "End", "planner_range_next_month": "Next month", "planner_range_pick": "Select range", "planner_range_prev_month": "Previous month", @@ -694,6 +704,7 @@ "habit_value_done": "Done", "habit_week_chip": "week", "habit_week_streak": "Week streak", + "habit_pick_date": "Pick date", "habit_xp_target": "+10 XP · Habit goal reached", "shell_add": "Add", "shell_collapse_menu": "Collapse menu", diff --git a/messages/pt-BR.json b/messages/pt-BR.json index a591060..aa27fb4 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -26,6 +26,12 @@ "settings_first_day_hint": "Afeta hábito semanal, metas e calendários.", "settings_day_mon": "Seg", "settings_day_sun": "Dom", + "settings_mascot_title": "Mascote", + "settings_mascot_hint": "Quem aparece no Atlas. Em breve.", + "settings_mascot_pro": "Trocar exige Atlas Pro", + "settings_mascot_atlas": "Atlas", + "settings_mascot_cat": "Gato", + "settings_mascot_ghost": "Ghost", "settings_diag_title": "Diagnóstico de notificações", "settings_diag_permission": "Permissão: {state}", "settings_diag_granted": "concedida", @@ -328,6 +334,10 @@ "planner_range_apply": "Aplicar", "planner_range_hint": "Toque no primeiro e no último dia.", "planner_range_max": "máx. {days} dias", + "time_window_title": "Janela de horário", + "time_window_hint": "Horas visíveis na grade", + "time_window_from": "Início", + "time_window_to": "Fim", "planner_range_next_month": "Próximo mês", "planner_range_pick": "Selecionar período", "planner_range_prev_month": "Mês anterior", @@ -694,6 +704,7 @@ "habit_value_done": "Feito", "habit_week_chip": "semana", "habit_week_streak": "Sequência da semana", + "habit_pick_date": "Escolher data", "habit_xp_target": "+10 XP · Meta do hábito alcançada", "shell_add": "Adicionar", "shell_collapse_menu": "Recolher menu", diff --git a/src/app.css b/src/app.css index 5476688..a211eb5 100644 --- a/src/app.css +++ b/src/app.css @@ -149,6 +149,13 @@ h1 { margin-bottom: 8px; color: #e9ece5; font: 600 clamp(28px, 3vw, 39px)/1.14 M .habit-prototype-ring strong { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 1px; color: var(--hp-text); font-size: 20px; font-weight: 800; }.habit-prototype-ring strong small { margin-left: 1px; color: var(--hp-muted); font-size: 12px; }.habit-prototype-hero h2 { margin: 0; color: var(--hp-text); font-size: 22px; font-weight: 800; letter-spacing: -.02em; }.habit-prototype-hero p { margin: 2px 0 0; color: var(--hp-muted); font-size: 13px; } .habit-prototype-week { display: flex; flex-direction: column; gap: 8px; justify-content: space-between; padding: 14px; }.habit-prototype-day { display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 0; border: 0; background: none; color: var(--hp-muted); font: inherit; font-size: 11px; cursor: pointer; }.habit-prototype-day:disabled { opacity: .35; cursor: default; }.habit-prototype-day span { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; background: var(--hp-s2); color: #141a0a; }.habit-prototype-day.done span { background: var(--hp-flame); }.habit-prototype-day.today span { box-shadow: 0 0 0 2px var(--hp-accent); }.habit-prototype-day.selected { color: var(--hp-flame); }.habit-prototype-day.selected span { box-shadow: 0 0 0 2px var(--hp-flame); }.habit-prototype-week.away { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--hp-flame) 45%, transparent); } .habit-week-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--hp-muted); font-size: 12px; font-weight: 600; }.habit-prototype-week.away .habit-week-head span { color: var(--hp-flame); }.habit-week-today { padding: 4px 10px; border-radius: 20px; background: color-mix(in srgb, var(--hp-flame) 18%, transparent); color: var(--hp-flame); font-size: 12px; font-weight: 700; } +.habit-week-open { min-width: 0; flex: 1; padding: 2px 0; border: 0; background: none; color: inherit; font: inherit; text-align: left; cursor: pointer; } +.habit-week-open span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +/* A área vazia do card também abre o seletor; os botões de dia ficam acima. */ +.habit-prototype-week { position: relative; } +.habit-week-open-area { position: absolute; inset: 0; z-index: 0; border: 0; border-radius: inherit; background: none; cursor: pointer; } +.habit-week-head, .habit-week-days { position: relative; z-index: 1; pointer-events: none; } +.habit-week-head button, .habit-week-days button { pointer-events: auto; } .habit-week-days { display: flex; justify-content: space-between; gap: 2px; } .habit-prototype-hero:disabled { cursor: default; }.habit-prototype-hero.readonly h2 { color: var(--hp-flame); } .habit-prototype-habits { margin-top: 6px; }.habit-prototype-habit { min-height: 116px; display: flex; flex-direction: column; justify-content: space-between; position: relative; overflow: hidden; padding: 14px; background: linear-gradient(to top, var(--hp-accent-dim) calc(var(--habit-progress,0) * 100%), var(--hp-s) 0); cursor: pointer; transition: background .35s, transform .15s; }.habit-prototype-habit:active { transform: scale(.97); }.habit-prototype-habit b { color: inherit; font-size: 16px; font-weight: 600; }.habit-prototype-value { display: inline-block; margin-top: 12px; color: var(--hp-accent); font-size: 30px; font-weight: 800; letter-spacing: -.04em; line-height: 1; }.habit-prototype-habit small { color: var(--hp-muted); font-size: 12px; }.habit-prototype-habit.done { background: var(--hp-accent); color: #141a0a; }.habit-prototype-habit.done .habit-prototype-value, .habit-prototype-habit.done small { color: #141a0a; }.habit-prototype-check { width: 26px; height: 26px; display: grid; place-items: center; position: absolute; top: 12px; right: 12px; border-radius: 50%; background: #141a0a; color: var(--hp-accent); } @@ -505,6 +512,9 @@ h1 { margin-bottom: 8px; color: #e9ece5; font: 600 clamp(28px, 3vw, 39px)/1.14 M .tp-ic { width: 38px; height: 38px; border-radius: 8px; background: var(--panel); font-size: 16px; text-align: center; color: var(--text); } .tp-pal { display: flex; gap: 6px; overflow-x: auto; padding: 0 0 8px; scrollbar-width: none; } .tp-pal::-webkit-scrollbar { display: none; } +/* Mobile: ações de range/resumo e o total abrem a linha de categorias. */ +.tp-pal-actions { display: none; flex: none; align-items: center; gap: 6px; } +.tp-chip { flex: none; padding: 5px 10px; border-radius: 14px; background: var(--panel); color: var(--muted); font-size: 11px; font-weight: 600; white-space: nowrap; } .tp-cat { flex: none; padding: 9px 13px; border-radius: 20px; background: var(--panel); font-weight: 600; font-size: 14px; display: flex; gap: 7px; align-items: center; transition: transform .12s; } .tp-cat i { width: 9px; height: 9px; border-radius: 50%; background: var(--cc); } .tp-cat small { color: var(--muted); font-size: 11px; font-weight: 600; } @@ -615,6 +625,9 @@ h1 { margin-bottom: 8px; color: #e9ece5; font: 600 clamp(28px, 3vw, 39px)/1.14 M .tp-prototype > .tp-pal { flex: none; } + /* O título já existe no header do shell; no mobile sobra só a grade. */ + .tp-top { display: none; } + .tp-pal-actions { display: flex; } .tp-scroll { flex: 1 1 auto; min-width: 0; @@ -1001,6 +1014,17 @@ h1 { margin-bottom: 8px; color: #e9ece5; font: 600 clamp(28px, 3vw, 39px)/1.14 M line-height: 1.5; white-space: nowrap; cursor: help; + user-select: none; + -webkit-user-select: none; + -webkit-touch-callout: none; + touch-action: manipulation; +} +/* A referência já é um chip: o `code` que a embrulha não pode virar outro chip. */ +.nt-pv :where(code):has(> .tp-ref) { + background: none; + padding: 0; + border-radius: 0; + font-size: inherit; } .nt-pv .tp-ref.ms { background: #ffffff12; color: var(--nt-mu); } @@ -1018,12 +1042,12 @@ h1 { margin-bottom: 8px; color: #e9ece5; font: 600 clamp(28px, 3vw, 39px)/1.14 M .tbb.in { pointer-events: auto; } .tbb-m { position: relative; - background: color-mix(in srgb, var(--tint, #d4f25a) 13%, #1d211db8); + background: color-mix(in srgb, var(--tint, #d4f25a) 13%, #1d211deb); backdrop-filter: blur(18px) saturate(1.5); -webkit-backdrop-filter: blur(18px) saturate(1.5); - border: 1px solid #ffffff22; + border: 1px solid #ffffff2e; border-radius: 12px; - box-shadow: 0 14px 34px #000a, inset 0 1px 0 #ffffff18; + box-shadow: 0 14px 34px #000a, inset 0 1px 0 #ffffff22; padding: 10px 12px; } .tbb-t { @@ -1032,13 +1056,13 @@ h1 { margin-bottom: 8px; color: #e9ece5; font: 600 clamp(28px, 3vw, 39px)/1.14 M left: var(--tx, 50%); width: 12px; height: 12px; - background: color-mix(in srgb, var(--tint, #d4f25a) 13%, #222722); - border-right: 1px solid #ffffff22; - border-bottom: 1px solid #ffffff22; + background: color-mix(in srgb, var(--tint, #d4f25a) 13%, #222722f2); + border-right: 1px solid #ffffff2e; + border-bottom: 1px solid #ffffff2e; border-radius: 0 0 3px 0; transform: translateX(-50%) rotate(45deg); } -.tbb-1 { font-size: 13px; font-weight: 700; color: var(--tx); } +.tbb-1 { font-size: 13px; font-weight: 700; color: var(--text); } .tbb-2 { font-size: 12px; color: var(--mu); } .tbb-lb { margin-bottom: 4px; color: var(--mu); font-size: 11px; } .tbb-r { diff --git a/src/lib/atlas.test.ts b/src/lib/atlas.test.ts index 735aa59..7e7e988 100644 --- a/src/lib/atlas.test.ts +++ b/src/lib/atlas.test.ts @@ -13,6 +13,8 @@ import { money, normalizeDailyGoal, normalizeFirstDayOfWeek, + normalizeMascot, + normalizeTimeWindow, nextBooleanLog, positiveValueOf, remapWeeklyLogs, @@ -369,6 +371,24 @@ describe('createEmptySnapshot', () => { it('a semana começa na segunda por padrão', () => { expect(createEmptySnapshot().preferences.firstDayOfWeek).toBe('mon'); }); + + it('janela de horas nasce em 07–22 e respeita o mínimo de 4h', () => { + const preferences = createEmptySnapshot().preferences; + expect(preferences.timeWindowStartHour).toBe(7); + expect(preferences.timeWindowEndHour).toBe(22); + expect(normalizeTimeWindow('9', '10')).toEqual({ startHour: 9, endHour: 12 }); + expect(normalizeTimeWindow(-5, 30)).toEqual({ startHour: 0, endHour: 23 }); + expect(normalizeTimeWindow(23, 23)).toEqual({ startHour: 20, endHour: 23 }); + expect(normalizeTimeWindow(undefined, undefined)).toEqual({ startHour: 7, endHour: 22 }); + }); + + it('mascote nasce em Atlas e só aceita as opções conhecidas', () => { + expect(createEmptySnapshot().preferences.mascot).toBe('atlas'); + expect(normalizeMascot('cat')).toBe('cat'); + expect(normalizeMascot('ghost')).toBe('ghost'); + expect(normalizeMascot('dog')).toBe('atlas'); + expect(normalizeMascot(undefined)).toBe('atlas'); + }); }); describe('demoSnapshot (modo de teste)', () => { diff --git a/src/lib/atlas.ts b/src/lib/atlas.ts index 751b66d..58f38dc 100644 --- a/src/lib/atlas.ts +++ b/src/lib/atlas.ts @@ -29,6 +29,40 @@ export const TIME_SLOT_START_HOUR = 7; export const TIME_SLOT_COUNT = 32; export const TIME_SLOT_MINUTES = 30; +/** Janela padrão do planejador: 07:00–22:00 (inclusiva nas duas pontas). */ +export const TIME_WINDOW_DEFAULT_START_HOUR = 7; +export const TIME_WINDOW_DEFAULT_END_HOUR = 22; +/** Mínimo de horas visíveis para a grade continuar útil. */ +export const TIME_WINDOW_MIN_HOURS = 4; + +export interface TimeWindow { + startHour: number; + endHour: number; +} + +/** Normaliza a janela de horas: inteiros 0–23, mínimo de `TIME_WINDOW_MIN_HOURS`. */ +export function normalizeTimeWindow(start: unknown, end: unknown): TimeWindow { + const hour = (value: unknown, fallback: number) => { + const number = Number(value); + if (!Number.isFinite(number)) return fallback; + return Math.min(23, Math.max(0, Math.round(number))); + }; + const startHour = Math.min( + 23 - (TIME_WINDOW_MIN_HOURS - 1), + hour(start, TIME_WINDOW_DEFAULT_START_HOUR) + ); + let endHour = hour(end, TIME_WINDOW_DEFAULT_END_HOUR); + if (endHour < startHour + TIME_WINDOW_MIN_HOURS - 1) endHour = startHour + TIME_WINDOW_MIN_HOURS - 1; + return { startHour, endHour }; +} + +/** Mascote do Atlas. `atlas` é o padrão; os outros exigem Atlas Pro no mobile. */ +export type MascotChoice = 'atlas' | 'cat' | 'ghost'; + +export function normalizeMascot(value: unknown): MascotChoice { + return value === 'cat' || value === 'ghost' ? value : 'atlas'; +} + export interface Transaction { id: string; type: TransactionType; @@ -158,6 +192,11 @@ export interface AppPreferences { backupEncryption: 'ask' | 'encrypted' | 'plain'; /** Primeiro dia da semana dos ciclos (hábito semanal, trackers, calendário). */ firstDayOfWeek: FirstDayOfWeek; + /** Mascote escolhido (só Atlas Pro troca no mobile). */ + mascot: MascotChoice; + /** Janela de horas visível no módulo Tempo (inclusiva nas duas pontas). */ + timeWindowStartHour: number; + timeWindowEndHour: number; } export interface AtlasSnapshot { @@ -249,7 +288,10 @@ export function createEmptySnapshot(): AtlasSnapshot { typingSoundEnabled: true, hapticsEnabled: true, backupEncryption: 'ask', - firstDayOfWeek: 'mon' + firstDayOfWeek: 'mon', + mascot: 'atlas', + timeWindowStartHour: TIME_WINDOW_DEFAULT_START_HOUR, + timeWindowEndHour: TIME_WINDOW_DEFAULT_END_HOUR }, graph: createGraph() }; diff --git a/src/lib/bubble.ts b/src/lib/bubble.ts index 29aea42..2d84403 100644 --- a/src/lib/bubble.ts +++ b/src/lib/bubble.ts @@ -145,6 +145,29 @@ export class TimeBubble { this.html = html; } + /** + * Fixa a bolha já aberta sem repetir a entrada: reposiciona na hora, troca o + * conteúdo e confirma com um pulso curto + haptic (long press no planejador). + */ + pin(anchor: HTMLElement, html: string, tint: string) { + clearTimeout(this.hideTimer); + this.el.style.setProperty('--tint', tint); + this.anchor = anchor; + this.html = html; + this.content.innerHTML = html; + this.el.style.display = 'block'; + this.open = true; + this.el.getAnimations().forEach((animation) => animation.cancel()); + this.body.getAnimations({ subtree: true }).forEach((animation) => animation.cancel()); + this.apply(this.place(anchor)); + this.vibrate(10); + if (this.reduced) return; + this.body.animate( + [{ transform: 'scale(1)' }, { transform: 'scale(1.03)' }, { transform: 'scale(1)' }], + { duration: 160, easing: 'ease-out' } + ); + } + private enter() { const anchor = this.anchor; if (!anchor) return; diff --git a/src/lib/components/notes/NoteEditor.svelte b/src/lib/components/notes/NoteEditor.svelte index ed3b9ff..38a8423 100644 --- a/src/lib/components/notes/NoteEditor.svelte +++ b/src/lib/components/notes/NoteEditor.svelte @@ -22,7 +22,7 @@ } from '#lib/notes'; import { saveNoteMarkdown } from '#lib/note-export'; import { playTone, playTypingThock, prefersReducedMotion, pulseHaptic } from '#lib/feedback'; - import { decorateTempoRefs, insideCodeAt, tempoReference } from '#lib/tempo-ref'; + import { decorateTempoRefs, insideCodeAt, mergeAdjacentBlocks, tempoReference } from '#lib/tempo-ref'; import { TimeBubble } from '#lib/bubble'; import { formatMinutes, type TimeBlock } from '#lib/atlas'; import { Clock, Download, MoreHorizontal } from '@lucide/svelte'; @@ -92,6 +92,11 @@ let morphing = $state(false); let slashOpen = $state(false); let slashStart = 0; + /** + * O textarea já recebeu um caret? Sem isso, `selectionStart` é 0 e o insert + * de tempo cairia no começo da nota quando o editor nunca foi focado. + */ + let caretTouched = false; let editorEl = $state(null); let previewEl = $state(null); let moreEl = $state(null); @@ -183,24 +188,25 @@ function openSlash() { if (!editorEl) return; - slashStart = editorEl.selectionStart; + slashStart = caretTouched ? editorEl.selectionStart : editorEl.value.length; slashOpen = true; - editorEl.focus(); + // Sem `focus()`: no mobile o teclado virtual abriria por cima das + // opções e o usuário não pediu para editar texto. } function insertTempo(block: TimeBlock) { const element = editorEl; if (!element) return; - const caret = element.selectionStart; + const caret = caretTouched ? element.selectionEnd : element.value.length; const start = Math.max(0, Math.min(slashStart, caret)); const prefix = start > 0 && !/\s/.test(element.value[start - 1]) ? ' ' : ''; const reference = tempoReference(block); const text = `${prefix}${insideCodeAt(element.value, start) ? reference : `\`${reference}\``}`; element.setRangeText(text, start, caret, 'end'); body = element.value; + caretTouched = true; slashOpen = false; markChange(); - element.focus(); tone(660, 0.07, 'triangle', 0.05); pulseHaptic(haptics, 5); } @@ -363,7 +369,58 @@ await openPath(await attachmentAbsolutePath(note.id, name)); } + const TP_HOLD_MS = 350; + let tpHoldTimer: ReturnType | undefined; + let tpHoldOrigin = { x: 0, y: 0 }; + let tpHoldReady = false; + let tpHoldSuppress = false; + + /** Hold no chip `tempo` mostra o bloco (sem selecionar o texto). */ + function onPreviewPointerDown(event: PointerEvent) { + const ref = (event.target as HTMLElement | null)?.closest('.tp-ref'); + if (!ref || event.button !== 0) return; + tpHoldReady = false; + tpHoldOrigin = { x: event.clientX, y: event.clientY }; + clearTimeout(tpHoldTimer); + tpHoldTimer = setTimeout(() => { + tpHoldTimer = undefined; + tpHoldReady = true; + pulseHaptic(haptics, 10); + }, TP_HOLD_MS); + } + + function onPreviewPointerMove(event: PointerEvent) { + const distance = Math.hypot(event.clientX - tpHoldOrigin.x, event.clientY - tpHoldOrigin.y); + if (tpHoldReady) { + if (distance > 8) tpHoldReady = false; + return; + } + if (!tpHoldTimer) return; + if (distance > 8) { + clearTimeout(tpHoldTimer); + tpHoldTimer = undefined; + } + } + + function onPreviewPointerUp(event: PointerEvent) { + clearTimeout(tpHoldTimer); + tpHoldTimer = undefined; + if (!tpHoldReady) return; + tpHoldReady = false; + const ref = (event.target as HTMLElement | null)?.closest('.tp-ref'); + if (!ref) return; + // O click sintetizado depois do hold não deve fechar a bolha que acabou de abrir. + tpHoldSuppress = true; + setTimeout(() => (tpHoldSuppress = false), 0); + showRefBubble(ref); + } + + function onPreviewContextMenu(event: MouseEvent) { + if ((event.target as HTMLElement | null)?.closest('.tp-ref')) event.preventDefault(); + } + function onPreviewClick(event: MouseEvent) { + if (tpHoldSuppress) return; const target = event.target as HTMLElement | null; const ref = target?.closest('.tp-ref'); if (ref) { @@ -381,14 +438,16 @@ function matchingBlocks(from: string, to: string): TimeBlock[] { if (!from || !to) return []; - return timeBlocks - .filter( - (block) => - block.status !== 'cancelled' && - from < block.endAt.slice(0, 16) && - to > block.startAt.slice(0, 16) - ) - .sort((a, b) => a.startAt.localeCompare(b.startAt)); + return mergeAdjacentBlocks( + timeBlocks + .filter( + (block) => + block.status !== 'cancelled' && + from < block.endAt.slice(0, 16) && + to > block.startAt.slice(0, 16) + ) + .sort((a, b) => a.startAt.localeCompare(b.startAt)) + ); } function escapeHtml(value: string): string { @@ -459,7 +518,16 @@ }); paneEl?.addEventListener('click', onPreviewClick); paneEl?.addEventListener('pointerover', onPreviewPointerOver); - paneEl?.addEventListener('pointerleave', () => bubble?.later()); + paneEl?.addEventListener('pointerdown', onPreviewPointerDown); + paneEl?.addEventListener('pointermove', onPreviewPointerMove); + paneEl?.addEventListener('pointerup', onPreviewPointerUp); + paneEl?.addEventListener('pointercancel', onPreviewPointerUp); + paneEl?.addEventListener('contextmenu', onPreviewContextMenu); + paneEl?.addEventListener('pointerleave', () => { + clearTimeout(tpHoldTimer); + tpHoldTimer = undefined; + bubble?.later(); + }); paneEl?.addEventListener('scroll', () => bubble?.hide(true), true); void renderPreview(); // iOS suspende timers ao ir para segundo plano: grava o rascunho @@ -574,6 +642,10 @@ spellcheck="false" aria-label={m.editor_body_label()} placeholder={m.editor_body_placeholder()} + onfocus={() => (caretTouched = true)} + onkeyup={() => (caretTouched = true)} + onpointerup={() => (caretTouched = true)} + onselect={() => (caretTouched = true)} oninput={(event) => { typingClick(); const element = event.currentTarget; diff --git a/src/lib/components/settings/SettingsSheet.svelte b/src/lib/components/settings/SettingsSheet.svelte index ab4272a..afe1652 100644 --- a/src/lib/components/settings/SettingsSheet.svelte +++ b/src/lib/components/settings/SettingsSheet.svelte @@ -1,6 +1,6 @@