mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-13 07:40:59 +00:00
0dd07befa7
The Plan tab was the last trip tab still in the old design. It now reads like Bookings, Costs, Lists, Files and Collab, without losing a function: - Day plan: each day is a card with a head band (day tile with weather, stay and rental pills, a "+" menu for place, stay, transport, transit and note), rows with "..." menus, white badges and a white route bar. - Places column: add, search and filters in three rows, a show dropdown, category and rating filters, and a selection bar at the foot. - Place inspector, day details and the stay editor on the shared dialog frame; the place form in the editor look with the name in the head band. - Clicking a booking or transport anywhere on the plan opens the booking detail popup first; Edit leads to the editor. The popup is one host shared with the Bookings and Transports tabs. - Files and Collab dialogs, notes, polls, links and chat moved to the same frame; shared DialogShell and dialogParts hold the building blocks. - The map hover card shows the place photo and follows the theme. Every new and reworded string is translated into all locales, and a few older English leftovers (help center, cost split, import hints) with them. Phone screens keep their own look.
122 lines
5.6 KiB
TypeScript
122 lines
5.6 KiB
TypeScript
import { useRef } from 'react'
|
|
import { Bold, Italic, Strikethrough, List, ListOrdered, Link2, Quote, Code } from 'lucide-react'
|
|
import { useTranslation } from '../../i18n'
|
|
import { Tooltip } from './Tooltip'
|
|
|
|
type FormatAction =
|
|
| { type: 'wrap'; before: string; after: string }
|
|
| { type: 'line'; prefix: string }
|
|
|
|
interface Props {
|
|
/** The field being formatted. Must be mounted before a button is pressed. */
|
|
textareaRef: React.RefObject<HTMLTextAreaElement | null>
|
|
/** Receives the whole new value — the field stays controlled by its owner. */
|
|
onChange: (value: string) => void
|
|
/** Rendered smaller, for the narrow note dialog. */
|
|
compact?: boolean
|
|
/** Mobile sheets run on their own token family, scoped to `.m-root`. */
|
|
variant?: 'desktop' | 'mobile'
|
|
/** Shows each button's name in the shared Tooltip instead of the browser's native one. */
|
|
customTooltips?: boolean
|
|
}
|
|
|
|
const ACTIONS: Array<{ icon: typeof Bold; labelKey: string; action: FormatAction }> = [
|
|
{ icon: Bold, labelKey: 'notes.format.bold', action: { type: 'wrap', before: '**', after: '**' } },
|
|
{ icon: Italic, labelKey: 'notes.format.italic', action: { type: 'wrap', before: '_', after: '_' } },
|
|
{ icon: Strikethrough, labelKey: 'notes.format.strike', action: { type: 'wrap', before: '~~', after: '~~' } },
|
|
{ icon: Code, labelKey: 'notes.format.code', action: { type: 'wrap', before: '`', after: '`' } },
|
|
{ icon: Link2, labelKey: 'notes.format.link', action: { type: 'wrap', before: '[', after: '](https://)' } },
|
|
{ icon: List, labelKey: 'notes.format.list', action: { type: 'line', prefix: '- ' } },
|
|
{ icon: ListOrdered, labelKey: 'notes.format.orderedList', action: { type: 'line', prefix: '1. ' } },
|
|
{ icon: Quote, labelKey: 'notes.format.quote', action: { type: 'line', prefix: '> ' } },
|
|
]
|
|
|
|
/**
|
|
* Formatting bar for the note fields (#1629).
|
|
*
|
|
* Notes have rendered Markdown for a long time, but only ever offered a bare
|
|
* textarea to write it in, so unless you already knew the syntax there was no
|
|
* way to find out it existed. This is that discovery surface.
|
|
*
|
|
* It edits Markdown rather than being a WYSIWYG editor on purpose: the stored
|
|
* value stays plain text that the PDF export, the plugin API and every existing
|
|
* note keep reading unchanged.
|
|
*
|
|
* Split from Journey's MarkdownToolbar, which is bound to the journal's own
|
|
* `--journal-*` variables and hardcodes English labels; this one runs on theme
|
|
* tokens and goes through i18n, so it can sit in the planner.
|
|
*/
|
|
export default function NoteFormatToolbar({ textareaRef, onChange, compact, variant = 'desktop', customTooltips = false }: Props) {
|
|
const { t } = useTranslation()
|
|
// Applied after the re-render, when the field holds the new text.
|
|
const pendingSelection = useRef<[number, number] | null>(null)
|
|
|
|
const apply = (action: FormatAction) => {
|
|
const ta = textareaRef.current
|
|
if (!ta) return
|
|
|
|
const { selectionStart: start, selectionEnd: end, value } = ta
|
|
const selected = value.slice(start, end)
|
|
let next: string
|
|
let caret: [number, number]
|
|
|
|
if (action.type === 'wrap') {
|
|
next = value.slice(0, start) + action.before + selected + action.after + value.slice(end)
|
|
caret = selected
|
|
// Keep the selection on the text, not on the markers around it, so a
|
|
// second click on another button nests instead of wrapping the syntax.
|
|
? [start + action.before.length, end + action.before.length]
|
|
: [start + action.before.length, start + action.before.length]
|
|
} else {
|
|
const lineStart = value.lastIndexOf('\n', start - 1) + 1
|
|
const alreadyPrefixed = value.startsWith(action.prefix, lineStart)
|
|
next = alreadyPrefixed
|
|
? value.slice(0, lineStart) + value.slice(lineStart + action.prefix.length)
|
|
: value.slice(0, lineStart) + action.prefix + value.slice(lineStart)
|
|
const shift = alreadyPrefixed ? -action.prefix.length : action.prefix.length
|
|
caret = [Math.max(lineStart, start + shift), Math.max(lineStart, end + shift)]
|
|
}
|
|
|
|
pendingSelection.current = caret
|
|
onChange(next)
|
|
|
|
requestAnimationFrame(() => {
|
|
const el = textareaRef.current
|
|
const sel = pendingSelection.current
|
|
if (!el || !sel) return
|
|
el.focus()
|
|
el.setSelectionRange(sel[0], sel[1])
|
|
pendingSelection.current = null
|
|
})
|
|
}
|
|
|
|
const size = compact ? 26 : 30
|
|
const buttonCls = variant === 'mobile'
|
|
? 'grid place-items-center rounded-[10px] border border-[color:var(--m-rowbr)] bg-[color:var(--m-ic)] text-m-muted active:bg-m-act active:text-m-actfg'
|
|
: 'grid place-items-center rounded-lg text-content-muted hover:text-content hover:bg-surface-hover transition-colors'
|
|
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-[2px]" role="toolbar" aria-label={t('notes.format.toolbar')}>
|
|
{ACTIONS.map(({ icon: Icon, labelKey, action }) => {
|
|
const button = (
|
|
<button
|
|
key={labelKey}
|
|
type="button"
|
|
title={customTooltips ? undefined : t(labelKey)}
|
|
aria-label={t(labelKey)}
|
|
// Buttons in a toolbar above a field must not steal focus, or the
|
|
// selection they are about to format is gone before the click lands.
|
|
onMouseDown={e => e.preventDefault()}
|
|
onClick={() => apply(action)}
|
|
className={buttonCls}
|
|
style={{ width: size, height: size, cursor: 'pointer', padding: 0, ...(variant === 'mobile' ? {} : { border: 0, background: 'none' }) }}
|
|
>
|
|
<Icon size={compact ? 13 : 15} strokeWidth={1.9} />
|
|
</button>
|
|
)
|
|
return customTooltips ? <Tooltip key={labelKey} label={t(labelKey)}>{button}</Tooltip> : button
|
|
})}
|
|
</div>
|
|
)
|
|
}
|