Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions web/src/components/paste-box.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useEffect, useRef } from 'react'

/**
* Somewhere to paste into, for when the browser will not be asked.
*
* Reading the clipboard from script is a permission, and a phone browser is
* entitled to refuse it — Safari in particular grants it only for a gesture
* it approves of, and answers everything else by doing nothing at all. That
* leaves flue's Paste looking broken for a reason no message can explain,
* because no error is raised: the promise simply never resolves the way it
* was asked to.
*
* Pasting *into a text field* is not a permission. It is the person choosing
* their own clipboard through their own operating system, and no browser has
* ever gated it. So this is the way that cannot be refused: a real, plain,
* ordinary input on the screen, long-pressed like any other, with the
* platform's own Paste in the callout above it. What lands here goes on to
* the terminal.
*
* A fallback and not the front door. When the clipboard can be read, one tap
* is better than three, and this never appears.
*/
export function PasteBox({ onText, onCancel }: { onText: (text: string) => void; onCancel: () => void }) {
const field = useRef<HTMLTextAreaElement>(null)

useEffect(() => {
// Focused on arrival so the callout is one press away rather than two,
// and so the keyboard that was already up does not drop while this opens.
field.current?.focus({ preventScroll: true })
}, [])

const send = (text: string) => {
if (text !== '') onText(text)
}

return (
<div
data-flue-paste-box=""
className="absolute right-3 bottom-20 left-3 z-20 rounded-lg bg-(--chip-bg) p-2 shadow-lg ring-1 ring-(--chip-ring) backdrop-blur-sm"
>
<p className="px-1 pb-1.5 text-xs text-(--chip-dim)">
This browser will not hand over the clipboard. Press and hold here, choose Paste, and it
goes to the terminal.
</p>
<div className="flex items-center gap-x-2">
<textarea
ref={field}
rows={1}
aria-label="Paste here"
// 16px, because iOS zooms the page in on a field any smaller and
// this one is meant to be typed into.
className="min-w-0 flex-1 resize-none rounded-md bg-(--chip-wash) px-2 py-1.5 text-[1rem] text-(--chip-fg) outline-none"
onPaste={(e) => {
// The paste event carries the text, and taking it from here rather
// than from the field a tick later is what keeps the terminal from
// seeing a half-applied value.
e.preventDefault()
send(e.clipboardData.getData('text'))
}}
// Not every keyboard raises a paste event — some clipboard managers
// and dictation insert text as ordinary input. Whatever arrives, the
// whole of it is the paste.
onInput={(e) => send(e.currentTarget.value)}
/>
<button
type="button"
onPointerDown={(e) => e.preventDefault()}
onClick={onCancel}
className="shrink-0 rounded-md px-3 py-1.5 text-sm/4 text-(--chip-dim) active:bg-(--chip-wash)"
>
Cancel
</button>
</div>
</div>
)
}
107 changes: 107 additions & 0 deletions web/src/components/selection-menu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { cn } from '@/lib/utils'

/**
* Which end of the terminal the menu sits at.
*
* There are two positions and not a coordinate, because the only thing the
* placement has to get right is not covering what was just selected. A menu
* pinned beside the selection would also have to dodge the control strip, the
* key bar and the edges of a scaled screen, for a precision nobody reading it
* would notice.
*/
export type MenuEnd = 'top' | 'bottom'

/**
* Copy and paste for a device with no keyboard to do either from.
*
* The gesture behind it lives in the terminal view: a long press selects the
* word under the finger and a drag widens the range, which is a thing flue has
* to do for itself. The OS would offer a menu of its own over real editable
* text, but a terminal rendered to a canvas has none — there is nothing on
* the page for a press to land on, and the browser has no idea the glyphs it
* drew are text at all.
*
* The buttons act on click and cancel their pointerdown, which is not what
* the key bar does and is deliberate. Cancelling the pointerdown is what
* keeps the press from taking focus off xterm's textarea, because losing it
* closes the keyboard — that much the key bar wants too. Acting on the click
* rather than on that same pointerdown is for Safari: reading the clipboard
* needs a user activation it accepts, and a cancelled pointerdown is not one,
* which is why Copy worked from there and Paste silently did not. Writing the
* clipboard is held to a far looser rule than reading it, and one menu that
* behaves the same way throughout beats two that differ for a reason nobody
* reading the markup could see. Cancelling a pointerdown suppresses the
* compatibility mouse events and never the click, so there is still a click
* to act on — and assistive technology's synthesised double-tap, which
* dispatches no pointer event at all, arrives here as one too.
*/
export function SelectionMenu({
at,
canCopy,
onCopy,
onPaste,
onCancel,
}: {
at: MenuEnd
/**
* Whether the press found a word.
*
* False leaves Copy out rather than showing it greyed, because the press
* that opens an empty prompt is a press asking to paste: the shortest menu
* that answers it is the right one, and a disabled button is a thing to
* read past on the way to the button you wanted.
*/
canCopy: boolean
onCopy: () => void
onPaste: () => void
onCancel: () => void
}) {
const chip = 'rounded-md px-3 py-1.5 text-sm/4 transition-colors select-none'
const press = (act: () => void) => ({
onPointerDown: (e: React.PointerEvent) => e.preventDefault(),
onClick: () => act(),
})
return (
<div
data-flue-selection-menu=""
role="toolbar"
aria-label="Selection"
className={cn(
'absolute left-1/2 z-20 flex -translate-x-1/2 items-center gap-x-1',
'rounded-lg bg-(--chip-bg) p-1 shadow-lg ring-1 ring-(--chip-ring) backdrop-blur-sm',
// Clear of the control strip at the top and of the key bar at the
// bottom, both of which are already spoken for.
at === 'top' ? 'top-16' : 'bottom-20',
)}
>
{canCopy && (
<button
type="button"
{...press(onCopy)}
className={cn(chip, 'font-medium text-(--chip-fg) active:bg-(--chip-wash)')}
>
Copy
</button>
)}
<button
type="button"
{...press(onPaste)}
className={cn(
chip,
'active:bg-(--chip-wash)',
// Whichever verb the press was asking for reads as the loud one.
canCopy ? 'text-(--chip-dim)' : 'font-medium text-(--chip-fg)',
)}
>
Paste
</button>
<button
type="button"
{...press(onCancel)}
className={cn(chip, 'text-(--chip-dim) active:bg-(--chip-wash)')}
>
Cancel
</button>
</div>
)
}
Loading