@anchrd/intel-ui 0.44.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/app/tree-move/tree-move.tsx +10 -2
- package/src/attachment-viewer/attachment-viewer.tsx +2 -2
- package/src/board/board-assignee/board-assignee-picker.tsx +21 -7
- package/src/board/board-assignee/board-assignee.tsx +32 -18
- package/src/board/board-table/board-table.tsx +5 -1
- package/src/board/board-task/board-task.tsx +18 -3
- package/src/file-preview/file-preview-view.tsx +118 -18
- package/src/i18n/de.json +2 -0
- package/src/i18n/en.json +2 -0
- package/src/i18n/es.json +2 -0
package/package.json
CHANGED
|
@@ -131,10 +131,16 @@ export function moveVerdict(input: {
|
|
|
131
131
|
return "ok";
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
-
// ⚠️
|
|
134
|
+
// ⚠️ Five refusals, five sentences. The service tells them apart by `code`
|
|
135
135
|
// (`nodes.ts:331/335/338/361`, `flows.ts:205–217/752`), and so does this: one message for all
|
|
136
136
|
// of them would leave the reader guessing which of "you may not write there", "that is not a
|
|
137
|
-
// folder", "that would be a loop" and "somebody else was faster"
|
|
137
|
+
// folder", "that would be a loop", "that destination is archived" and "somebody else was faster"
|
|
138
|
+
// they have just hit.
|
|
139
|
+
//
|
|
140
|
+
// ⚠️ `parent_archived` reaches this since #679: before that the service raised it for tasks only,
|
|
141
|
+
// which never travel through the tree's move. The board is not a drop target for an archived node,
|
|
142
|
+
// so a person meets it rarely — but a move over MCP or HTTP hits it, and the tree reloads from the
|
|
143
|
+
// same error.
|
|
138
144
|
export function moveErrorKey(error: unknown): string {
|
|
139
145
|
const code =
|
|
140
146
|
typeof error === "object" && error !== null && "code" in error
|
|
@@ -146,6 +152,8 @@ export function moveErrorKey(error: unknown): string {
|
|
|
146
152
|
return "tree.move.failed.forbidden";
|
|
147
153
|
case "parent_not_folder":
|
|
148
154
|
return "tree.move.failed.notFolder";
|
|
155
|
+
case "parent_archived":
|
|
156
|
+
return "tree.move.failed.archived";
|
|
149
157
|
case "move_cycle":
|
|
150
158
|
return "tree.move.failed.cycle";
|
|
151
159
|
case "update_conflict":
|
|
@@ -203,8 +203,8 @@ function ImagePreview({ file, title }: { file: File; title: string }) {
|
|
|
203
203
|
return () => URL.revokeObjectURL(next);
|
|
204
204
|
}, [file]);
|
|
205
205
|
return url ? (
|
|
206
|
-
<div className="grid h-full min-h-64 place-items-center overflow-auto
|
|
207
|
-
<img src={url} alt={title} className="max-h-full max-w-full object-contain" />
|
|
206
|
+
<div className="grid h-full min-h-64 place-items-center overflow-auto">
|
|
207
|
+
<img src={url} alt={title} className="h-auto w-auto max-h-full max-w-full object-contain" />
|
|
208
208
|
</div>
|
|
209
209
|
) : null;
|
|
210
210
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { useQuery } from "@tanstack/react-query";
|
|
2
|
-
import { useState } from "react";
|
|
2
|
+
import { type ReactNode, useState } from "react";
|
|
3
3
|
import {
|
|
4
4
|
DropdownMenu,
|
|
5
5
|
DropdownMenuContent,
|
|
6
6
|
DropdownMenuItem,
|
|
7
7
|
DropdownMenuSeparator,
|
|
8
|
+
DropdownMenuTrigger,
|
|
8
9
|
} from "@/components/ui/dropdown-menu.tsx";
|
|
9
10
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
10
11
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
@@ -24,12 +25,21 @@ export function AssigneePicker({
|
|
|
24
25
|
boardId,
|
|
25
26
|
current,
|
|
26
27
|
onPick,
|
|
27
|
-
|
|
28
|
+
open,
|
|
29
|
+
onOpenChange,
|
|
30
|
+
children,
|
|
28
31
|
}: {
|
|
29
32
|
boardId: string;
|
|
30
33
|
current: string | null;
|
|
31
34
|
onPick(assigneeId: string | null): void;
|
|
32
|
-
|
|
35
|
+
// Controlled only where somebody else decides when it opens — the plus menu does, the chip does
|
|
36
|
+
// not. Left out, the trigger below governs it on its own.
|
|
37
|
+
open?: boolean;
|
|
38
|
+
onOpenChange?(next: boolean): void;
|
|
39
|
+
// ⚠️ THE ANCHOR, and it is not optional decoration (#723). Radix positions a menu against its
|
|
40
|
+
// trigger; the first version of this component had none, and the menu never appeared. Every test
|
|
41
|
+
// stayed green, because jsdom has no layout and Testing Library finds a portal wherever it sits.
|
|
42
|
+
children: ReactNode;
|
|
33
43
|
}) {
|
|
34
44
|
const i18n = useI18n();
|
|
35
45
|
const { data } = useIntelRouterContext();
|
|
@@ -43,7 +53,11 @@ export function AssigneePicker({
|
|
|
43
53
|
const found = search.data?.items ?? [];
|
|
44
54
|
|
|
45
55
|
return (
|
|
46
|
-
<DropdownMenu
|
|
56
|
+
<DropdownMenu
|
|
57
|
+
{...(open === undefined ? {} : { open })}
|
|
58
|
+
{...(onOpenChange === undefined ? {} : { onOpenChange })}
|
|
59
|
+
>
|
|
60
|
+
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
|
47
61
|
<DropdownMenuContent align="start" className="w-64">
|
|
48
62
|
<div className="p-1">
|
|
49
63
|
<input
|
|
@@ -53,7 +67,7 @@ export function AssigneePicker({
|
|
|
53
67
|
aria-label={i18n.t("board.assignee.search")}
|
|
54
68
|
placeholder={i18n.t("board.assignee.search")}
|
|
55
69
|
onChange={(event) => setQuery(event.target.value)}
|
|
56
|
-
onKeyDown={(event) => event.key === "Escape" &&
|
|
70
|
+
onKeyDown={(event) => event.key === "Escape" && onOpenChange?.(false)}
|
|
57
71
|
className="w-full rounded-md border bg-background px-2 py-1 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
58
72
|
/>
|
|
59
73
|
</div>
|
|
@@ -61,7 +75,7 @@ export function AssigneePicker({
|
|
|
61
75
|
<>
|
|
62
76
|
<DropdownMenuItem
|
|
63
77
|
onSelect={() => {
|
|
64
|
-
|
|
78
|
+
onOpenChange?.(false);
|
|
65
79
|
onPick(null);
|
|
66
80
|
}}
|
|
67
81
|
>
|
|
@@ -84,7 +98,7 @@ export function AssigneePicker({
|
|
|
84
98
|
<DropdownMenuItem
|
|
85
99
|
key={person.id}
|
|
86
100
|
onSelect={() => {
|
|
87
|
-
|
|
101
|
+
onOpenChange?.(false);
|
|
88
102
|
onPick(person.id);
|
|
89
103
|
}}
|
|
90
104
|
>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AssigneePicker } from "@/board/board-assignee/board-assignee-picker.tsx";
|
|
1
2
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
2
3
|
import { initials } from "@/user-name/user-name.ts";
|
|
3
4
|
import { useAssigneeLabel } from "./board-assignee.ts";
|
|
@@ -14,32 +15,45 @@ import { useAssigneeLabel } from "./board-assignee.ts";
|
|
|
14
15
|
* ⚠️ **Only ever drawn for somebody.** "Nobody yet" as a chip is a placeholder for an absence, and
|
|
15
16
|
* an absence needs no place on screen (#692).
|
|
16
17
|
*
|
|
18
|
+
* ⚠️ **The circle OPENS the picker; it no longer clears on click** (#723). A control that
|
|
19
|
+
* removes an assignment on the same click somebody uses to change one is a control that
|
|
20
|
+
* destroys work on a misclick. Removing lives inside the menu, where it says what it does.
|
|
21
|
+
*
|
|
17
22
|
* ⚠️ **The NAME lives on the button, and the circle is hidden.** Children of a `<button>` are
|
|
18
23
|
* presentational in ARIA, so a label inside one is never announced and the button's own name wins.
|
|
19
24
|
* Wrapping the circle in a control therefore takes the person out of the accessibility tree unless
|
|
20
25
|
* the control says it too. That is the trap in `packages/ui/CLAUDE.md`.
|
|
21
26
|
*/
|
|
22
|
-
export function AssigneeChip({
|
|
27
|
+
export function AssigneeChip({
|
|
28
|
+
id,
|
|
29
|
+
boardId,
|
|
30
|
+
onPick,
|
|
31
|
+
}: {
|
|
32
|
+
id: string;
|
|
33
|
+
boardId: string;
|
|
34
|
+
onPick(assigneeId: string | null): void;
|
|
35
|
+
}) {
|
|
23
36
|
const i18n = useI18n();
|
|
24
37
|
const label = useAssigneeLabel(id);
|
|
25
38
|
return (
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
>
|
|
33
|
-
<span
|
|
34
|
-
// ⚠️ **Hidden, and it must stay hidden.** This span used to carry `role="img"` with the
|
|
35
|
-
// person's name, and that reached nobody: it sits inside a button. Giving it a role and a
|
|
36
|
-
// label back would not add a second announcement, it would add none, while making the code
|
|
37
|
-
// look as though the name were covered here rather than on the button.
|
|
38
|
-
aria-hidden="true"
|
|
39
|
-
className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
|
|
39
|
+
<AssigneePicker boardId={boardId} current={id} onPick={onPick}>
|
|
40
|
+
<button
|
|
41
|
+
type="button"
|
|
42
|
+
aria-label={i18n.t("board.changeAssignee", { name: label })}
|
|
43
|
+
title={label}
|
|
44
|
+
className="shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
40
45
|
>
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
<span
|
|
47
|
+
// ⚠️ **Hidden, and it must stay hidden.** This span used to carry `role="img"` with the
|
|
48
|
+
// person's name, and that reached nobody: it sits inside a button. Giving it a role and a
|
|
49
|
+
// label back would not add a second announcement, it would add none, while making the code
|
|
50
|
+
// look as though the name were covered here rather than on the button.
|
|
51
|
+
aria-hidden="true"
|
|
52
|
+
className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
|
|
53
|
+
>
|
|
54
|
+
{initials(label)}
|
|
55
|
+
</span>
|
|
56
|
+
</button>
|
|
57
|
+
</AssigneePicker>
|
|
44
58
|
);
|
|
45
59
|
}
|
|
@@ -599,7 +599,11 @@ function RowContent({
|
|
|
599
599
|
) : null}
|
|
600
600
|
|
|
601
601
|
{visible.has("assignee") && task.assigneeId !== null ? (
|
|
602
|
-
<AssigneeChip
|
|
602
|
+
<AssigneeChip
|
|
603
|
+
id={task.assigneeId}
|
|
604
|
+
boardId={board.boardId}
|
|
605
|
+
onPick={(assigneeId) => write({ assigneeId })}
|
|
606
|
+
/>
|
|
603
607
|
) : null}
|
|
604
608
|
</span>
|
|
605
609
|
);
|
|
@@ -132,7 +132,11 @@ export function BoardTaskDocument({
|
|
|
132
132
|
{/* ⚠️ **Last, always.** It is the only round element in the row; standing between the chips
|
|
133
133
|
it breaks the line, and a row without one simply ends earlier. */}
|
|
134
134
|
{task.assigneeId === null ? null : (
|
|
135
|
-
<AssigneeChip
|
|
135
|
+
<AssigneeChip
|
|
136
|
+
id={task.assigneeId}
|
|
137
|
+
boardId={board.boardId}
|
|
138
|
+
onPick={(assigneeId) => write({ assigneeId })}
|
|
139
|
+
/>
|
|
136
140
|
)}
|
|
137
141
|
|
|
138
142
|
<Adder task={task} board={board} write={write} />
|
|
@@ -279,8 +283,19 @@ function Adder({
|
|
|
279
283
|
boardId={board.boardId}
|
|
280
284
|
current={task.assigneeId}
|
|
281
285
|
onPick={(assigneeId) => write({ assigneeId })}
|
|
282
|
-
|
|
283
|
-
|
|
286
|
+
open
|
|
287
|
+
onOpenChange={(next) => !next && close()}
|
|
288
|
+
>
|
|
289
|
+
{/* ⚠️ The anchor. Opened from the plus menu there is nothing on screen to hang the menu
|
|
290
|
+
on, so the picker brings its own pill — the same shape the blocker branch below uses,
|
|
291
|
+
and for the same reason. */}
|
|
292
|
+
<button
|
|
293
|
+
type="button"
|
|
294
|
+
className="rounded-full border px-2.5 py-0.5 text-xs outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
295
|
+
>
|
|
296
|
+
{i18n.t("board.add.assignee")}
|
|
297
|
+
</button>
|
|
298
|
+
</AssigneePicker>
|
|
284
299
|
)}
|
|
285
300
|
|
|
286
301
|
{kind === null || kind === "blocker" || kind === "assignee" ? null : (
|
|
@@ -2,6 +2,7 @@ import FileViewer, {
|
|
|
2
2
|
type FileViewerHandle,
|
|
3
3
|
type ViewerOptions,
|
|
4
4
|
type ViewerState,
|
|
5
|
+
type ViewerViewState,
|
|
5
6
|
} from "@file-viewer/react";
|
|
6
7
|
import {
|
|
7
8
|
ChevronLeft,
|
|
@@ -35,15 +36,16 @@ const viewerTheme = `
|
|
|
35
36
|
}
|
|
36
37
|
.pdf-shell,.pdf-wrapper{background:var(--muted)!important;color:var(--foreground)!important}
|
|
37
38
|
.pdf-nav-pane,.pdf-nav-tabs,.pdf-nav-head{background:var(--background)!important;border-color:var(--border)!important}
|
|
38
|
-
@media (min-width:721px){.pdf-shell:not(.pdf-shell--nav-hidden) .pdf-content{grid-template-columns:
|
|
39
|
-
.
|
|
39
|
+
@media (min-width:721px){.pdf-shell:not(.pdf-shell--nav-hidden) .pdf-content{grid-template-columns:4rem minmax(0,1fr)!important}}
|
|
40
|
+
.pdfViewer{padding-inline:0!important}.pdfViewer .page{border-inline:0!important}
|
|
41
|
+
.pdf-nav-head{display:none!important}.pdf-nav-tabs{display:flex!important;justify-content:center;padding:4px 0!important}
|
|
40
42
|
.pdf-nav-tabs:not([data-outline-available="true"]){display:none!important}.pdf-nav-tabs button.active{display:none!important}
|
|
41
43
|
.pdf-nav-tabs button{width:30px;min-width:30px;padding:0;border:0!important;box-shadow:none!important;font-size:0;color:var(--muted-foreground)!important}
|
|
42
44
|
.pdf-nav-tabs button svg{width:15px;height:15px;margin:auto;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
|
|
43
45
|
.pdf-nav-tabs button:hover,.pdf-nav-tabs button.active{background:var(--accent)!important;border:0!important;color:var(--accent-foreground)!important}
|
|
44
|
-
.pdf-page-list{gap:
|
|
46
|
+
.pdf-page-list{gap:2px!important;padding:4px 0!important}.pdf-page-button{display:flex!important;width:28px!important;min-width:28px!important;height:28px!important;min-height:28px!important;margin-inline:auto!important;padding:0!important;border:0!important;box-shadow:none!important;background:transparent!important;color:var(--foreground)!important;justify-content:center!important}
|
|
45
47
|
.pdf-page-button:hover,.pdf-page-button--active{background:var(--accent)!important;border:0!important;box-shadow:none!important}
|
|
46
|
-
.pdf-page-thumb{width:
|
|
48
|
+
.pdf-page-thumb{width:24px!important;height:24px!important;border:0!important;background:transparent!important;color:var(--foreground)!important}
|
|
47
49
|
.pdf-page-label{display:none!important}.pdf-outline-button{color:var(--foreground)!important}.pdf-outline-button:hover{background:var(--accent)!important;border-color:var(--border)!important}
|
|
48
50
|
`;
|
|
49
51
|
|
|
@@ -60,6 +62,8 @@ export function FilePreviewView({
|
|
|
60
62
|
const i18n = useI18n();
|
|
61
63
|
const viewer = useRef<FileViewerHandle>(null);
|
|
62
64
|
const searchInput = useRef<HTMLInputElement>(null);
|
|
65
|
+
const pdfFit = useRef({ enabled: true, page: 0, pageWidthAtScaleOne: 0, rotation: 0 });
|
|
66
|
+
const schedulePdfFit = useRef<(() => void) | null>(null);
|
|
63
67
|
const [state, setState] = useState<ViewerState | null>(null);
|
|
64
68
|
const [searchOpen, setSearchOpen] = useState(false);
|
|
65
69
|
const [query, setQuery] = useState("");
|
|
@@ -74,7 +78,10 @@ export function FilePreviewView({
|
|
|
74
78
|
locale: "auto",
|
|
75
79
|
toolbar: false,
|
|
76
80
|
...(pdf
|
|
77
|
-
? {
|
|
81
|
+
? {
|
|
82
|
+
fit: { mode: "width", resize: "until-interaction", padding: 0 },
|
|
83
|
+
pdf: { toolbar: false, navigation: true, defaultNavigationVisible: false },
|
|
84
|
+
}
|
|
78
85
|
: {}),
|
|
79
86
|
ui: { density: "compact", surfaceBackground: "transparent" },
|
|
80
87
|
}),
|
|
@@ -85,15 +92,28 @@ export function FilePreviewView({
|
|
|
85
92
|
const pageCount = viewState?.pageCount ?? 0;
|
|
86
93
|
const scale = state?.zoom?.scale ?? viewState?.scale ?? 1;
|
|
87
94
|
const search = state?.search;
|
|
95
|
+
const latestView = useRef({ page, rotation: viewState?.rotation ?? 0, scale });
|
|
96
|
+
const pdfPageRequest = useRef({ page, pending: false });
|
|
97
|
+
latestView.current = { page, rotation: viewState?.rotation ?? 0, scale };
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const request = pdfPageRequest.current;
|
|
101
|
+
if (!request.pending || request.page === page) {
|
|
102
|
+
request.page = page;
|
|
103
|
+
request.pending = false;
|
|
104
|
+
}
|
|
105
|
+
}, [page]);
|
|
88
106
|
|
|
89
107
|
useEffect(() => {
|
|
90
108
|
if (!state?.ready) return;
|
|
91
109
|
const root = viewer.current?.getController()?.container.shadowRoot;
|
|
92
|
-
if (!root
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
110
|
+
if (!root) return;
|
|
111
|
+
if (!root.querySelector("style[data-intel-viewer-theme]")) {
|
|
112
|
+
const style = document.createElement("style");
|
|
113
|
+
style.dataset.intelViewerTheme = "true";
|
|
114
|
+
style.textContent = viewerTheme;
|
|
115
|
+
root.append(style);
|
|
116
|
+
}
|
|
97
117
|
const adaptNavigation = () => {
|
|
98
118
|
for (const button of root.querySelectorAll<HTMLButtonElement>(".pdf-page-button")) {
|
|
99
119
|
const label = button.querySelector(".pdf-page-label")?.textContent?.trim();
|
|
@@ -173,12 +193,79 @@ export function FilePreviewView({
|
|
|
173
193
|
};
|
|
174
194
|
}, [state?.ready]);
|
|
175
195
|
|
|
196
|
+
useEffect(() => {
|
|
197
|
+
if (!pdf || !state?.ready) return;
|
|
198
|
+
const root = viewer.current?.getController()?.container.shadowRoot;
|
|
199
|
+
if (!root) return;
|
|
200
|
+
let frame = 0;
|
|
201
|
+
const fitPdfToWidth = () => {
|
|
202
|
+
cancelAnimationFrame(frame);
|
|
203
|
+
frame = requestAnimationFrame(() => {
|
|
204
|
+
const fitState = pdfFit.current;
|
|
205
|
+
if (!fitState.enabled) return;
|
|
206
|
+
const wrapper = root.querySelector<HTMLElement>(".pdf-wrapper");
|
|
207
|
+
const current = latestView.current;
|
|
208
|
+
const currentPage =
|
|
209
|
+
root.querySelector<HTMLElement>(`.pdfViewer .page[data-page-number="${current.page}"]`) ??
|
|
210
|
+
root.querySelector<HTMLElement>(".pdfViewer .page");
|
|
211
|
+
const renderedWidth = currentPage?.getBoundingClientRect().width ?? 0;
|
|
212
|
+
const availableWidth = wrapper?.clientWidth ?? 0;
|
|
213
|
+
if (renderedWidth <= 0 || availableWidth <= 0 || current.scale <= 0) return;
|
|
214
|
+
if (fitState.page !== current.page || fitState.rotation !== current.rotation) {
|
|
215
|
+
fitState.page = current.page;
|
|
216
|
+
fitState.rotation = current.rotation;
|
|
217
|
+
fitState.pageWidthAtScaleOne = 0;
|
|
218
|
+
}
|
|
219
|
+
if (fitState.pageWidthAtScaleOne <= 0) {
|
|
220
|
+
fitState.pageWidthAtScaleOne = renderedWidth / current.scale;
|
|
221
|
+
}
|
|
222
|
+
// The pinned PDF renderer clamps scale to two decimals. Floor to the same precision so the
|
|
223
|
+
// page never overflows and sub-pixel differences do not schedule identical updates.
|
|
224
|
+
const targetScale = Math.floor((availableWidth / fitState.pageWidthAtScaleOne) * 100) / 100;
|
|
225
|
+
if (Math.abs(targetScale - current.scale) <= 0.001) return;
|
|
226
|
+
void viewer.current?.applyViewState({ scale: targetScale });
|
|
227
|
+
});
|
|
228
|
+
};
|
|
229
|
+
schedulePdfFit.current = fitPdfToWidth;
|
|
230
|
+
const wrapper = root.querySelector<HTMLElement>(".pdf-wrapper");
|
|
231
|
+
const resizeObserver =
|
|
232
|
+
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(fitPdfToWidth);
|
|
233
|
+
if (wrapper) resizeObserver?.observe(wrapper);
|
|
234
|
+
const mutationObserver = new MutationObserver(fitPdfToWidth);
|
|
235
|
+
mutationObserver.observe(root, { childList: true, subtree: true });
|
|
236
|
+
fitPdfToWidth();
|
|
237
|
+
return () => {
|
|
238
|
+
cancelAnimationFrame(frame);
|
|
239
|
+
resizeObserver?.disconnect();
|
|
240
|
+
mutationObserver.disconnect();
|
|
241
|
+
if (schedulePdfFit.current === fitPdfToWidth) schedulePdfFit.current = null;
|
|
242
|
+
};
|
|
243
|
+
}, [pdf, state?.ready]);
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
if (page > 0 && viewState?.rotation !== undefined) schedulePdfFit.current?.();
|
|
247
|
+
}, [page, viewState?.rotation]);
|
|
248
|
+
|
|
176
249
|
useEffect(() => {
|
|
177
250
|
if (searchOpen) searchInput.current?.focus();
|
|
178
251
|
}, [searchOpen]);
|
|
179
252
|
|
|
180
|
-
const applyViewState = (next:
|
|
181
|
-
|
|
253
|
+
const applyViewState = (next: ViewerViewState) => viewer.current?.applyViewState(next);
|
|
254
|
+
const goToPdfPage = (direction: -1 | 1) => {
|
|
255
|
+
const request = pdfPageRequest.current;
|
|
256
|
+
const nextPage = Math.max(1, Math.min(pageCount, request.page + direction));
|
|
257
|
+
request.page = nextPage;
|
|
258
|
+
request.pending = nextPage !== page;
|
|
259
|
+
const root = viewer.current?.getController()?.container.shadowRoot;
|
|
260
|
+
const pageElement = root?.querySelector<HTMLElement>(
|
|
261
|
+
`.pdfViewer .page[data-page-number="${nextPage}"]`,
|
|
262
|
+
);
|
|
263
|
+
if (pageElement) {
|
|
264
|
+
pageElement.scrollIntoView({ block: "start", inline: "nearest" });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
void applyViewState({ page: nextPage });
|
|
268
|
+
};
|
|
182
269
|
|
|
183
270
|
return (
|
|
184
271
|
<div className="relative h-full min-h-64">
|
|
@@ -217,7 +304,7 @@ export function FilePreviewView({
|
|
|
217
304
|
aria-label={i18n.t("attachment.viewer.controls")}
|
|
218
305
|
className={`absolute right-3 bottom-2 left-3 z-[5] mx-auto flex min-h-10 w-fit max-w-[calc(100%-1.5rem)] items-center gap-1 rounded-full border bg-background/80 p-1 shadow-lg backdrop-blur-md ${
|
|
219
306
|
viewState?.navigation?.visible
|
|
220
|
-
? "[@media(max-width:720px)]:hidden min-[721px]:left-[calc(
|
|
307
|
+
? "[@media(max-width:720px)]:hidden min-[721px]:left-[calc(4rem+0.75rem)]"
|
|
221
308
|
: ""
|
|
222
309
|
}`}
|
|
223
310
|
>
|
|
@@ -237,7 +324,7 @@ export function FilePreviewView({
|
|
|
237
324
|
type="button"
|
|
238
325
|
aria-label={i18n.t("attachment.viewer.previousPage")}
|
|
239
326
|
disabled={page <= 1}
|
|
240
|
-
onClick={() =>
|
|
327
|
+
onClick={() => goToPdfPage(-1)}
|
|
241
328
|
className="size-7 rounded-full hover:bg-accent disabled:opacity-40"
|
|
242
329
|
>
|
|
243
330
|
<ChevronLeft aria-hidden="true" className="mx-auto size-3.5" />
|
|
@@ -249,7 +336,7 @@ export function FilePreviewView({
|
|
|
249
336
|
type="button"
|
|
250
337
|
aria-label={i18n.t("attachment.viewer.nextPage")}
|
|
251
338
|
disabled={page >= pageCount}
|
|
252
|
-
onClick={() =>
|
|
339
|
+
onClick={() => goToPdfPage(1)}
|
|
253
340
|
className="size-7 rounded-full hover:bg-accent disabled:opacity-40"
|
|
254
341
|
>
|
|
255
342
|
<ChevronRight aria-hidden="true" className="mx-auto size-3.5" />
|
|
@@ -340,7 +427,10 @@ export function FilePreviewView({
|
|
|
340
427
|
type="button"
|
|
341
428
|
aria-label={i18n.t("attachment.viewer.zoomOut")}
|
|
342
429
|
disabled={state.zoom ? !state.zoom.canZoomOut : false}
|
|
343
|
-
onClick={() =>
|
|
430
|
+
onClick={() => {
|
|
431
|
+
pdfFit.current.enabled = false;
|
|
432
|
+
void viewer.current?.zoomOut();
|
|
433
|
+
}}
|
|
344
434
|
className="grid size-7 place-items-center rounded-full hover:bg-accent disabled:opacity-40"
|
|
345
435
|
>
|
|
346
436
|
<Minus aria-hidden="true" className="size-3.5" />
|
|
@@ -349,7 +439,14 @@ export function FilePreviewView({
|
|
|
349
439
|
type="button"
|
|
350
440
|
aria-label={i18n.t("attachment.viewer.resetZoom")}
|
|
351
441
|
disabled={state.zoom ? !state.zoom.canReset : false}
|
|
352
|
-
onClick={() =>
|
|
442
|
+
onClick={() => {
|
|
443
|
+
if (pdf) {
|
|
444
|
+
pdfFit.current.enabled = true;
|
|
445
|
+
schedulePdfFit.current?.();
|
|
446
|
+
return;
|
|
447
|
+
}
|
|
448
|
+
void viewer.current?.resetZoom();
|
|
449
|
+
}}
|
|
353
450
|
className="min-w-12 px-1 text-xs tabular-nums disabled:opacity-40"
|
|
354
451
|
>
|
|
355
452
|
{Math.round(scale * 100)}%
|
|
@@ -358,7 +455,10 @@ export function FilePreviewView({
|
|
|
358
455
|
type="button"
|
|
359
456
|
aria-label={i18n.t("attachment.viewer.zoomIn")}
|
|
360
457
|
disabled={state.zoom ? !state.zoom.canZoomIn : false}
|
|
361
|
-
onClick={() =>
|
|
458
|
+
onClick={() => {
|
|
459
|
+
pdfFit.current.enabled = false;
|
|
460
|
+
void viewer.current?.zoomIn();
|
|
461
|
+
}}
|
|
362
462
|
className="grid size-7 place-items-center rounded-full hover:bg-accent disabled:opacity-40"
|
|
363
463
|
>
|
|
364
464
|
<Plus aria-hidden="true" className="size-3.5" />
|
package/src/i18n/de.json
CHANGED
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"board.assignee.search": "Person suchen",
|
|
91
91
|
"board.backTo": "Zurück zu {board}",
|
|
92
92
|
"board.cardLabel": "{title} öffnen, in {column}. Alt und eine Pfeiltaste verschiebt sie.",
|
|
93
|
+
"board.changeAssignee": "Zuständig: {name}, ändern",
|
|
93
94
|
"board.clearAssignee": "Zuständigkeit von {name} entfernen",
|
|
94
95
|
"board.clearDependsOn": "nicht mehr darauf warten",
|
|
95
96
|
"board.clearDue": "entfernen",
|
|
@@ -504,6 +505,7 @@
|
|
|
504
505
|
"tree.move.destination": "Neuer Ort: {title}",
|
|
505
506
|
"tree.move.dropRoot": "Hier ablegen, um auf die oberste Ebene zu verschieben",
|
|
506
507
|
"tree.move.elsewhere": "Anderen Ordner wählen",
|
|
508
|
+
"tree.move.failed.archived": "Nicht verschoben: Dieses Ziel ist archiviert. Stell es zuerst wieder her.",
|
|
507
509
|
"tree.move.failed.conflict": "Nicht verschoben: Jemand anderes hat diesen Eintrag zuerst geändert. Der Baum wurde neu geladen — sieh noch einmal nach und verschiebe dann.",
|
|
508
510
|
"tree.move.failed.cycle": "Nicht verschoben: Ein Ordner kann weder in sich selbst noch in etwas, das er enthält.",
|
|
509
511
|
"tree.move.failed.forbidden": "Nicht verschoben: Du darfst in diesen Ordner nicht schreiben. Bitte dort um Schreibzugriff.",
|
package/src/i18n/en.json
CHANGED
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"board.assignee.search": "Find a person",
|
|
91
91
|
"board.backTo": "Back to {board}",
|
|
92
92
|
"board.cardLabel": "Open {title}, in {column}. Alt and an arrow key moves it.",
|
|
93
|
+
"board.changeAssignee": "Assigned to {name}, change",
|
|
93
94
|
"board.clearAssignee": "Take the assignment off {name}",
|
|
94
95
|
"board.clearDependsOn": "stop waiting for it",
|
|
95
96
|
"board.clearDue": "clear it",
|
|
@@ -504,6 +505,7 @@
|
|
|
504
505
|
"tree.move.destination": "New place: {title}",
|
|
505
506
|
"tree.move.dropRoot": "Drop here to move to the top level",
|
|
506
507
|
"tree.move.elsewhere": "Choose another folder",
|
|
508
|
+
"tree.move.failed.archived": "It was not moved: that destination is archived. Restore it first.",
|
|
507
509
|
"tree.move.failed.conflict": "It was not moved: somebody else changed this entry first. The tree has been reloaded — look again, then move it.",
|
|
508
510
|
"tree.move.failed.cycle": "It was not moved: a folder cannot be put inside itself or inside anything it contains.",
|
|
509
511
|
"tree.move.failed.forbidden": "It was not moved: you may not write into that folder. Ask for write access there.",
|
package/src/i18n/es.json
CHANGED
|
@@ -90,6 +90,7 @@
|
|
|
90
90
|
"board.assignee.search": "Buscar una persona",
|
|
91
91
|
"board.backTo": "Volver a {board}",
|
|
92
92
|
"board.cardLabel": "Abrir {title}, en {column}. Alt y una flecha la mueve.",
|
|
93
|
+
"board.changeAssignee": "Responsable: {name}, cambiar",
|
|
93
94
|
"board.clearAssignee": "Quitar la responsabilidad de {name}",
|
|
94
95
|
"board.clearDependsOn": "dejar de esperarlo",
|
|
95
96
|
"board.clearDue": "quitar",
|
|
@@ -504,6 +505,7 @@
|
|
|
504
505
|
"tree.move.destination": "Sitio nuevo: {title}",
|
|
505
506
|
"tree.move.dropRoot": "Suelta aquí para mover al nivel superior",
|
|
506
507
|
"tree.move.elsewhere": "Elegir otra carpeta",
|
|
508
|
+
"tree.move.failed.archived": "No se ha movido: ese destino está archivado. Restáuralo primero.",
|
|
507
509
|
"tree.move.failed.conflict": "No se ha movido: otra persona ha cambiado esta entrada antes. El árbol se ha recargado — míralo otra vez y muévelo entonces.",
|
|
508
510
|
"tree.move.failed.cycle": "No se ha movido: una carpeta no puede ir dentro de sí misma ni dentro de nada que contenga.",
|
|
509
511
|
"tree.move.failed.forbidden": "No se ha movido: no puedes escribir en esa carpeta. Pide acceso de escritura allí.",
|