The board places each card in the shortest column, instead of leaving it to CSS columns
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / integration (push) Successful in 1m37s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m58s
CI & Build / Python tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 40s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m38s
Android / Build the server image (push) Successful in 1s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m43s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / integration (push) Successful in 1m37s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m58s
CI & Build / Python tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 40s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m38s
Android / Build the server image (push) Successful in 1s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m43s
Desktop (Tauri) / Update manifest (push) Skipped
CSS multi-column decides placement by balancing column heights, so the same notes could land differently at the same width: an edit left two cards alone in the fourth column with the middle two empty, and a refresh left the fourth empty beside a stack of three in the first. Cards now go, in board order, into the shortest column, leftmost on a tie, so the same notes at the same width always land the same way and every column fills before any stacks. This changes the reading order to match Android: the second note sits top-right rather than under the first. useMasonry measures the cards and watches them for height changes. Placement is a pure function, tested in notes/masonry.test.ts. Cards are absolutely placed, so TransitionGroup's move animation carries on working unchanged. Issue #6709. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -530,7 +530,7 @@ private fun DrawerRow(
|
|||||||
* Staggered rather than a uniform grid because notes are wildly different heights
|
* Staggered rather than a uniform grid because notes are wildly different heights
|
||||||
* — a one-line thought beside a twelve-item checklist — and forcing them to a
|
* — a one-line thought beside a twelve-item checklist — and forcing them to a
|
||||||
* common height either clips the long ones or strands whitespace under the short
|
* common height either clips the long ones or strands whitespace under the short
|
||||||
* ones. This is the Compose equivalent of the CSS multi-column `NoteGrid.vue` uses.
|
* ones. `NoteGrid.vue` places cards the same way, each into the shortest column.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
private fun NoteBoard(
|
private fun NoteBoard(
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ const tagColors = computed<Record<string, string>>(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
ref="root"
|
ref="root"
|
||||||
class="group relative mb-2 break-inside-avoid sm:mb-4 rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
|
class="group relative rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
|
||||||
:class="[
|
:class="[
|
||||||
NOTE_CARD_SURFACE,
|
NOTE_CARD_SURFACE,
|
||||||
dragging ? 'opacity-40' : '',
|
dragging ? 'opacity-40' : '',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NoteCard from "./NoteCard.vue";
|
import NoteCard from "./NoteCard.vue";
|
||||||
|
import { useMasonry } from "../composables/useMasonry";
|
||||||
import type { Note } from "../stores/notes";
|
import type { Note } from "../stores/notes";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -9,7 +10,7 @@ import type { Note } from "../stores/notes";
|
|||||||
* `activeId` rather than an index, because the board splits its notes across two
|
* `activeId` rather than an index, because the board splits its notes across two
|
||||||
* grids.
|
* grids.
|
||||||
*/
|
*/
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
notes: Note[];
|
notes: Note[];
|
||||||
/** The keyboard-focused note, if any. */
|
/** The keyboard-focused note, if any. */
|
||||||
activeId?: string | null;
|
activeId?: string | null;
|
||||||
@@ -24,22 +25,27 @@ defineEmits<{
|
|||||||
(e: "drop", targetId: string): void;
|
(e: "drop", targetId: string): void;
|
||||||
(e: "reminder-changed"): void;
|
(e: "reminder-changed"): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const { grid, gridStyle, cardStyle } = useMasonry(() => props.notes.map((n) => n.id));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- `board` is the transition name defined in style.css; `appear` covers first
|
<!-- `board` is the transition name defined in style.css; `appear` covers first
|
||||||
paint so a lens arrives eased rather than snapping in.
|
paint so a lens arrives eased rather than snapping in.
|
||||||
Two columns even on a phone, as on Android, with a tighter gap there.
|
Two columns even on a phone, as on Android, with a tighter gap there.
|
||||||
NoteCard's bottom margin is the vertical half of this gap. -->
|
useMasonry reads the column count and the gap back from these classes. -->
|
||||||
<TransitionGroup
|
<TransitionGroup
|
||||||
|
ref="grid"
|
||||||
tag="div"
|
tag="div"
|
||||||
name="board"
|
name="board"
|
||||||
appear
|
appear
|
||||||
class="columns-2 gap-2 sm:gap-4 lg:columns-3 xl:columns-4"
|
class="relative [--board-columns:2] [--board-gap:0.5rem] [column-gap:var(--board-gap)] sm:[--board-gap:1rem] lg:[--board-columns:3] xl:[--board-columns:4]"
|
||||||
|
:style="gridStyle"
|
||||||
>
|
>
|
||||||
<NoteCard
|
<NoteCard
|
||||||
v-for="n in notes"
|
v-for="n in notes"
|
||||||
:key="n.id"
|
:key="n.id"
|
||||||
|
:style="cardStyle(n.id)"
|
||||||
:note="n"
|
:note="n"
|
||||||
:active="activeId === n.id"
|
:active="activeId === n.id"
|
||||||
:reorderable="reorderable"
|
:reorderable="reorderable"
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export function endCardDrag(): void {
|
|||||||
/**
|
/**
|
||||||
* The id of the card under this point, ignoring `self`.
|
* The id of the card under this point, ignoring `self`.
|
||||||
*
|
*
|
||||||
* Reads the DOM rather than tracking geometry: in a CSS masonry (`columns-*`) the
|
* Reads the DOM rather than tracking geometry: in a masonry the visual order isn't
|
||||||
* visual order isn't derivable from the model order.
|
* derivable from the model order.
|
||||||
*/
|
*/
|
||||||
export function cardIdAt(x: number, y: number, self: string): string | null {
|
export function cardIdAt(x: number, y: number, self: string): string | null {
|
||||||
const el = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-note-id]");
|
const el = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-note-id]");
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
|
||||||
|
import type { ComponentPublicInstance } from "vue";
|
||||||
|
import { placeCards } from "../notes/masonry";
|
||||||
|
import type { CardSlot } from "../notes/masonry";
|
||||||
|
|
||||||
|
/** A slot as inline style. The column count and gap stay in CSS, on the grid's own
|
||||||
|
* classes, so a card has the right width before anything has been measured. */
|
||||||
|
function slotStyle(slot: CardSlot | undefined): Record<string, string> {
|
||||||
|
return {
|
||||||
|
position: "absolute",
|
||||||
|
top: `${slot?.top ?? 0}px`,
|
||||||
|
left: `calc(${slot?.column ?? 0} * (100% + var(--board-gap)) / var(--board-columns))`,
|
||||||
|
width: "calc((100% - (var(--board-columns) - 1) * var(--board-gap)) / var(--board-columns))",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lays out a NoteGrid: measures its cards and places each one with `placeCards`.
|
||||||
|
*
|
||||||
|
* `grid` is the template ref for the TransitionGroup. Its cards are found by
|
||||||
|
* `data-note-id`, which NoteCard sets on its root.
|
||||||
|
*/
|
||||||
|
export function useMasonry(ids: () => string[]) {
|
||||||
|
const grid = ref<ComponentPublicInstance | null>(null);
|
||||||
|
const heights = shallowRef<ReadonlyMap<string, number>>(new Map());
|
||||||
|
const columns = ref(1);
|
||||||
|
const gap = ref(0);
|
||||||
|
|
||||||
|
const layout = computed(() => placeCards(ids(), heights.value, columns.value, gap.value));
|
||||||
|
const gridStyle = computed(() => ({ height: `${layout.value.height}px` }));
|
||||||
|
const cardStyle = (id: string) => slotStyle(layout.value.slots.get(id));
|
||||||
|
|
||||||
|
const gridEl = () => (grid.value?.$el ?? null) as HTMLElement | null;
|
||||||
|
const cardEls = () => [...(gridEl()?.querySelectorAll<HTMLElement>(":scope > [data-note-id]") ?? [])];
|
||||||
|
|
||||||
|
function readTracks(): void {
|
||||||
|
const el = gridEl();
|
||||||
|
if (!el) return;
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
columns.value = parseInt(style.getPropertyValue("--board-columns"), 10) || 1;
|
||||||
|
gap.value = parseFloat(style.columnGap) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function measure(): void {
|
||||||
|
const cards = cardEls();
|
||||||
|
const wanted = new Set(ids());
|
||||||
|
const next = new Map<string, number>();
|
||||||
|
for (const card of cards) {
|
||||||
|
const id = card.dataset.noteId as string;
|
||||||
|
// A card on its way out is still in the DOM, and no longer takes up room.
|
||||||
|
if (wanted.has(id)) next.set(id, card.offsetHeight);
|
||||||
|
}
|
||||||
|
const same = next.size === heights.value.size && [...next].every(([id, h]) => heights.value.get(id) === h);
|
||||||
|
if (same) return;
|
||||||
|
|
||||||
|
// A card seen for the first time goes straight to its place. Left to the next
|
||||||
|
// render, TransitionGroup would see it move there from the top of the board and
|
||||||
|
// fly it across.
|
||||||
|
const { slots } = placeCards(ids(), next, columns.value, gap.value);
|
||||||
|
for (const card of cards) {
|
||||||
|
const id = card.dataset.noteId as string;
|
||||||
|
if (wanted.has(id) && !heights.value.has(id)) Object.assign(card.style, slotStyle(slots.get(id)));
|
||||||
|
}
|
||||||
|
heights.value = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Height follows content that has no event of its own: an edit, an image loading,
|
||||||
|
// a width change rewrapping the text.
|
||||||
|
const cardObserver = new ResizeObserver(measure);
|
||||||
|
const gridObserver = new ResizeObserver(readTracks);
|
||||||
|
|
||||||
|
function observeCards(): void {
|
||||||
|
cardObserver.disconnect();
|
||||||
|
for (const card of cardEls()) cardObserver.observe(card);
|
||||||
|
measure();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const el = gridEl();
|
||||||
|
if (el) gridObserver.observe(el);
|
||||||
|
readTracks();
|
||||||
|
observeCards();
|
||||||
|
});
|
||||||
|
watch(() => ids().join("\n"), observeCards, { flush: "post" });
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cardObserver.disconnect();
|
||||||
|
gridObserver.disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
return { grid, gridStyle, cardStyle };
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { placeCards } from "./masonry";
|
||||||
|
|
||||||
|
const heights = (entries: Record<string, number>) => new Map(Object.entries(entries));
|
||||||
|
|
||||||
|
describe("placing cards on the board", () => {
|
||||||
|
it("uses every column before stacking", () => {
|
||||||
|
const { slots } = placeCards(["a", "b", "c", "d"], heights({ a: 100, b: 100, c: 100, d: 100 }), 4, 16);
|
||||||
|
expect([...slots.values()].map((s) => s.column)).toEqual([0, 1, 2, 3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the next card under the shortest column, with the gap above it", () => {
|
||||||
|
const { slots } = placeCards(["a", "b", "c"], heights({ a: 300, b: 50, c: 80 }), 2, 16);
|
||||||
|
expect(slots.get("c")).toEqual({ column: 1, top: 66 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes the leftmost column on a tie", () => {
|
||||||
|
const { slots } = placeCards(["a", "b", "c"], heights({ a: 100, b: 100, c: 10 }), 2, 16);
|
||||||
|
expect(slots.get("c")).toEqual({ column: 0, top: 116 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports the tallest column as the board's height", () => {
|
||||||
|
const { height } = placeCards(["a", "b", "c"], heights({ a: 300, b: 50, c: 80 }), 2, 16);
|
||||||
|
expect(height).toBe(316);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts an unmeasured card as zero tall", () => {
|
||||||
|
const { slots } = placeCards(["a", "b"], heights({ b: 100 }), 1, 16);
|
||||||
|
expect(slots.get("b")).toEqual({ column: 0, top: 16 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is an empty board with no cards", () => {
|
||||||
|
const { slots, height } = placeCards([], new Map(), 4, 16);
|
||||||
|
expect(slots.size).toBe(0);
|
||||||
|
expect(height).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/** Where one card sits on the board. */
|
||||||
|
export interface CardSlot {
|
||||||
|
column: number;
|
||||||
|
top: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BoardLayout {
|
||||||
|
slots: Map<string, CardSlot>;
|
||||||
|
/** The tallest column, so the board can reserve its own height. */
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places cards in board order, each into the column that is currently shortest and
|
||||||
|
* the leftmost of any tie. The same notes at the same width always land the same way.
|
||||||
|
*
|
||||||
|
* A card with no measured height yet counts as zero.
|
||||||
|
*/
|
||||||
|
export function placeCards(
|
||||||
|
ids: string[],
|
||||||
|
heights: ReadonlyMap<string, number>,
|
||||||
|
columns: number,
|
||||||
|
gap: number,
|
||||||
|
): BoardLayout {
|
||||||
|
const bottoms = new Array<number>(Math.max(columns, 1)).fill(0);
|
||||||
|
const slots = new Map<string, CardSlot>();
|
||||||
|
for (const id of ids) {
|
||||||
|
const column = bottoms.indexOf(Math.min(...bottoms));
|
||||||
|
slots.set(id, { column, top: bottoms[column] });
|
||||||
|
bottoms[column] += (heights.get(id) ?? 0) + gap;
|
||||||
|
}
|
||||||
|
return { slots, height: Math.max(...bottoms) };
|
||||||
|
}
|
||||||
@@ -131,10 +131,8 @@ body {
|
|||||||
* Vue's TransitionGroup does the FLIP itself; this supplies only the curve, fast and
|
* Vue's TransitionGroup does the FLIP itself; this supplies only the curve, fast and
|
||||||
* small, so a card reads as having MOVED rather than performed.
|
* small, so a card reads as having MOVED rather than performed.
|
||||||
*
|
*
|
||||||
* Leavers are NOT taken out of flow with `position: absolute`, the usual trick: this
|
* Cards are absolutely placed (useMasonry), so a leaver fades where it stands while
|
||||||
* masonry is CSS multi-column, and an absolutely positioned child escapes its column
|
* the others move up. The leave is the shortest of the three.
|
||||||
* to the container's origin. Staying in flow costs a small settle on removal, so the
|
|
||||||
* leave is the shortest of the three.
|
|
||||||
*
|
*
|
||||||
* Reduced motion needs no case here: the global guard above collapses every duration. */
|
* Reduced motion needs no case here: the global guard above collapses every duration. */
|
||||||
.board-move {
|
.board-move {
|
||||||
|
|||||||
Reference in New Issue
Block a user