key nudge for all drawing types

This commit is contained in:
Chris Amow 2026-08-13 19:02:49 -05:00
parent 05e8aef40b
commit 54f4871757
6 changed files with 125 additions and 25 deletions

View file

@ -796,6 +796,15 @@ levels before price falls into them. Blank retains the ATR default. Backend
tests pin both approach directions and persistence; the browser test pins list
editing, snapped handle movement and a one-tick keyboard nudge.
Arrow-key nudging now applies to every drawing type. Up/Down shifts levels,
both trendline endpoints, and pinned annotations by one ES tick; Left/Right
shifts trendlines and pinned annotations by one visible-timeframe bar. Floating
comments move four screen pixels instead because they deliberately have no
market coordinates. Shift multiplies each movement by four. Every drawing row,
including comments and symbols, has a selection checkbox so a mixed selection
can move together. Repeated keypresses are serialized: without that, PATCH
responses can arrive out of order and a held arrow can move a drawing backward.
### 2026-08-11 — a time axis past the last bar, and the Yahoo bar it exposed
Trendlines project into the whitespace right of the last candle, but the axis

View file

@ -171,6 +171,7 @@ createApp({
let socket = null;
let retryDelay = 2000;
let timer = null;
let nudgeQueue = Promise.resolve();
const barAge = computed(() => {
if (!status.value.last_bar_t) return '—';
@ -185,8 +186,9 @@ createApp({
});
const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
const hasDrawingSelection = computed(() => selectedDrawings.value.length > 0 || selectedDrawing.value != null);
const allManualSelected = computed(() => manualLines.value.length > 0
&& manualLines.value.every(line => selectedDrawings.value.includes(line.id)));
const allManualSelected = computed(() => manualLines.value.length + comments.value.length > 0
&& [...manualLines.value, ...comments.value]
.every(drawing => selectedDrawings.value.includes(drawing.id)));
// One list for everything drawn. Lines come from the levels socket, which
// keeps them live; comments come from the drawings endpoint.
@ -695,7 +697,7 @@ createApp({
}
function toggleSelectAll() {
selectedDrawings.value = allManualSelected.value ? [] : manualLines.value.map(line => line.id);
selectedDrawings.value = allManualSelected.value ? [] : drawings.value.map(drawing => drawing.id);
selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
}
@ -746,6 +748,75 @@ createApp({
syncVisibleLevels();
}
async function updateCommentGeometry(comment, changes) {
const response = await apiFetch(`/api/lines/${encodeURIComponent(comment.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(changes),
});
if (!response.ok) { console.error(`Unable to move drawing: HTTP ${response.status}`); return; }
const saved = await response.json();
comments.value = comments.value.map(value => value.id === comment.id ? saved : value);
syncComments();
}
async function nudgeSelectedDrawings(key, multiplier) {
const ids = selectedDrawings.value.length
? [...selectedDrawings.value] : [selectedDrawing.value].filter(Boolean);
if (!ids.length || !chartApi) return;
const vertical = key === 'ArrowUp' ? multiplier : key === 'ArrowDown' ? -multiplier : 0;
const horizontal = key === 'ArrowRight' ? multiplier : key === 'ArrowLeft' ? -multiplier : 0;
for (const id of ids) {
const item = drawings.value.find(value => value.id === id);
if (!item) continue;
if (item.line) {
const line = { ...item.line };
if (vertical) line.anchor_p = ConfluenceChart.snapToTick(line.anchor_p + vertical * ConfluenceChart.TICK);
if (line.slope === 0) {
if (vertical) await updateLineGeometry(line);
continue;
}
const endPrice = item.line.anchor_p
+ item.line.slope * (item.line.last_t - item.line.anchor_t)
+ vertical * ConfluenceChart.TICK;
if (horizontal) {
line.anchor_t = chartApi.timeAtIndex(chartApi.indexAt(line.anchor_t) + horizontal);
line.last_t = chartApi.timeAtIndex(chartApi.indexAt(line.last_t) + horizontal);
if (line.cutoff_t != null) {
line.cutoff_t = chartApi.timeAtIndex(chartApi.indexAt(line.cutoff_t) + horizontal);
}
}
line.slope = (endPrice - line.anchor_p) / (line.last_t - line.anchor_t);
await updateLineGeometry(line);
continue;
}
const comment = item.comment;
if (comment.pinned) {
const changes = {};
if (vertical) {
changes.anchor_p = ConfluenceChart.snapToTick(
comment.anchor_p + vertical * ConfluenceChart.TICK,
);
}
if (horizontal) {
changes.anchor_t = chartApi.timeAtIndex(
chartApi.indexAt(comment.anchor_t) + horizontal,
);
}
if (Object.keys(changes).length) await updateCommentGeometry(comment, changes);
} else {
if (!chartApi.chartEl) continue;
const pixels = 4 * multiplier;
const clamp = value => Math.min(Math.max(value, 0), 1);
await updateCommentGeometry(comment, {
x: clamp(comment.x + (key === 'ArrowRight' ? pixels : key === 'ArrowLeft' ? -pixels : 0)
/ chartApi.chartEl.clientWidth),
y: clamp(comment.y + (key === 'ArrowDown' ? pixels : key === 'ArrowUp' ? -pixels : 0)
/ chartApi.chartEl.clientHeight),
});
}
}
}
async function endLineHere(line) {
const response = await apiFetch(`/api/lines/${encodeURIComponent(line.id)}`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
@ -790,13 +861,12 @@ createApp({
}
}
if (isEditing(event.target)) return;
if ((event.key === 'ArrowUp' || event.key === 'ArrowDown') && chartApi) {
const direction = event.key === 'ArrowUp' ? 1 : -1;
if (chartApi.nudgeSelectedFlat(direction)) {
if (event.key.startsWith('Arrow') && hasDrawingSelection.value) {
event.preventDefault();
const multiplier = event.shiftKey ? 4 : 1;
nudgeQueue = nudgeQueue.then(() => nudgeSelectedDrawings(event.key, multiplier));
return;
}
}
if ((event.key === 'Delete' || event.key === 'Backspace') && hasDrawingSelection.value) {
event.preventDefault();
deleteSelected();

View file

@ -1680,17 +1680,6 @@ class ConfluenceChart {
if (level && this.onLineChange) this.onLineChange({ ...level });
}
nudgeSelectedFlat(direction) {
const level = this.levels.find(value =>
value.id === this.selectedLineId && value.kind === 'manual' && ConfluenceChart.isFlat(value));
if (!level) return false;
level.anchor_p = ConfluenceChart.snapToTick(level.anchor_p + direction * ConfluenceChart.TICK);
this.priceLines.get(level.id)?.line.applyOptions({ price: level.anchor_p });
this.renderAnchorHandles();
if (this.onLineChange) this.onLineChange({ ...level });
return true;
}
showContextMenu(event) {
const level = this.levels.find(
value => value.id === this.selectedLineId && value.kind === 'manual' && !ConfluenceChart.isFlat(value),

View file

@ -169,10 +169,9 @@
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
:class="{selected: selectedDrawings.includes(item.id)}"
@click="focusDrawing(item)">
<input v-if="item.line" class="line-select" type="checkbox"
<input class="line-select" type="checkbox"
:checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
@click.stop @change="toggleDrawingSelection(item.id)">
<span v-else class="line-select drawing-icon"><i :class="item.kind === 'symbol' ? `fa-solid fa-${item.comment.icon}` : 'fa-solid fa-note-sticky'"></i></span>
<div class="drawing-content">
<div class="drawing-primary">

View file

@ -49,10 +49,21 @@ test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, as
const anchor = await page.evaluate(t => {
const c = window.__chart.comments.find(x => x.note === t);
return c ? c.anchor_t : null;
return c ? { t: c.anchor_t, p: c.anchor_p } : null;
}, TEXT);
assert.ok(anchor, 'the comment was not created');
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
await page.locator('.trendline-row').click();
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Shift+ArrowRight');
await page.waitForFunction(([text, original]) => {
const c = window.__chart;
const comment = c.comments.find(value => value.note === text);
return comment?.anchor_p === original.p + 0.25
&& Math.round(c.indexAt(comment.anchor_t) - c.indexAt(original.t)) === 4;
}, [TEXT, anchor]);
// The bug: timeToCoordinate answers only for exact data points, so a 30m
// bucket returned null on 15m and null was read as "off the left edge".
for (const tf of ['15m', '1h', '30m']) {
@ -99,10 +110,17 @@ test('a floating comment holds its position while the chart scrolls', { timeout:
await placeComment(page, box, 0.4, 0.3, { floating: true });
const before = await commentNode(page);
assert.ok(before, 'the floating comment was not created');
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
await page.locator('.trendline-row').click();
await page.keyboard.press('ArrowRight');
await page.waitForFunction(([text, left]) => {
const node = [...document.querySelectorAll('.chart-comment')].find(value => value.title === text);
return node && Math.round(node.getBoundingClientRect().left) === left + 4;
}, [TEXT, before.left]);
await page.evaluate(() => window.__chart.chart.timeScale().scrollToPosition(-300, false));
await page.waitForTimeout(1200);
const after = await commentNode(page);
assert.equal(after.left, before.left, 'a floating comment moved with the chart');
assert.equal(after.left, before.left + 4, 'a floating comment moved with the chart');
assertNoPageErrors(page, assert);
});
});

View file

@ -425,11 +425,26 @@ test('dragging the selected line body translates both endpoints together',
assert.ok(Math.abs(endPriceShift - anchorPriceShift) < 1e-6,
'the endpoints moved by different price amounts');
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Shift+ArrowRight');
await page.waitForFunction(expected => {
const c = window.__chart;
const line = c.levels.find(level => level.id === expected.id);
return line?.anchor_p === expected.p
&& Math.round(c.indexAt(line.anchor_t) - expected.index) === 4;
}, { id: moved.line.id, p: moved.line.anchor_p + 0.25, index: moved.anchorIndex });
const nudged = await page.evaluate(id => {
const c = window.__chart;
const line = c.levels.find(level => level.id === id);
return { anchor_t: line.anchor_t, anchor_p: line.anchor_p };
}, moved.line.id);
await page.waitForFunction(async expected => {
const response = await fetch('/api/drawings');
const drawing = (await response.json()).drawings.find(item => item.id === expected.id);
return drawing?.anchor_t === expected.anchor_t && drawing?.anchor_p === expected.anchor_p;
}, { id: moved.line.id, anchor_t: moved.line.anchor_t, anchor_p: moved.line.anchor_p });
}, { id: moved.line.id, ...nudged });
assertNoPageErrors(page, assert);
});
});