fix: URLs im Aufgabentitel als anklickbaren Link darstellen
Eine im Titel getippte URL war bisher reiner Text innerhalb des klickbaren Bereichs
der Aufgabe -- ein Klick darauf hat je nach Ansicht die Aufgabe durchgestrichen
(Tagesraster, GridTaskBlock) oder den Bearbeiten-Modus geoeffnet (Kanban/Someday/
Liste/Prioritaet, weekly/TaskItem.tsx und PriorityView.tsx), statt den Link zu oeffnen.
- src/lib/weekly/format.ts: neue reine Funktion splitTextAndUrls zerlegt einen Titel
in Text- und URL-Abschnitte (http(s)://, www.-Adressen). Abschliessende Satzzeichen
(".", ",", ")", ...) bleiben Text, nicht Teil des Links.
- src/components/weekly/LinkifiedText.tsx: rendert diese Abschnitte, URL-Abschnitte als
echtes <a target="_blank" rel="noopener noreferrer"> mit stopPropagation, damit ein
Klick ausschliesslich den Link oeffnet und nicht an den umgebenden Klick-Handler
durchgereicht wird. Kein Titel ohne URL bekommt ein zusaetzliches Wrapper-Element.
- Eingebaut ueberall dort, wo ein Aufgabentitel bislang als reiner Text stand:
GridTaskBlock.tsx, weekly/TaskItem.tsx, PriorityView.tsx.
Keine Aenderung an Daten, API oder dem separaten task.url-Feld (der eigene "Termin-
Link"-Button in TaskItem.tsx) -- reine Darstellungsaenderung.
8 neue Unit-Tests fuer splitTextAndUrls (Titel ohne URL, nur URL, URL mit Text davor/
danach, www.-Form, Satzzeichen am Ende, Klammern, zwei URLs im selben Titel).
Verifiziert: Typecheck, Lint (0 Fehler), 108 Unit-Tests (8 neu), Produktions-Build.
v1.128.0
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
2042c54b2d
commit
7f850bb4e9
16
CHANGELOG.md
16
CHANGELOG.md
@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
All notable changes to this project are documented in this file.
|
All notable changes to this project are documented in this file.
|
||||||
|
|
||||||
|
## [1.128.0] - 2026-09-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Eine im Aufgabentitel getippte URL war kein Link, sondern nur Text** — ein Klick
|
||||||
|
darauf hat je nach Ansicht die Aufgabe durchgestrichen (Tagesraster) oder den
|
||||||
|
Bearbeiten-Modus geöffnet (Kanban/Someday/Liste/Priorität), statt den Link zu öffnen.
|
||||||
|
Neue reine Erkennungsfunktion `splitTextAndUrls` (`src/lib/weekly/format.ts`, 8
|
||||||
|
Unit-Tests) zerlegt den Titel in Text- und URL-Abschnitte; die neue Komponente
|
||||||
|
`LinkifiedText` (`src/components/weekly/LinkifiedText.tsx`) rendert URL-Abschnitte
|
||||||
|
als echten Link mit `stopPropagation`, damit ein Klick darauf ausschließlich den Link
|
||||||
|
öffnet. Eingebaut in `GridTaskBlock.tsx`, `weekly/TaskItem.tsx` und `PriorityView.tsx`
|
||||||
|
— überall dort, wo ein Aufgabentitel als reiner Text stand. Keine Änderung an Daten,
|
||||||
|
API oder dem separaten `task.url`-Feld.
|
||||||
|
|
||||||
|
Verifiziert: Typecheck, Lint (0 Fehler), 108 Unit-Tests (8 neu), Produktions-Build.
|
||||||
|
|
||||||
## [1.127.0] - 2026-09-14
|
## [1.127.0] - 2026-09-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "my-weekly-todo-list",
|
"name": "my-weekly-todo-list",
|
||||||
"version": "1.127.0",
|
"version": "1.128.0",
|
||||||
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
"description": "A web-based weekly task management application that organizes to-dos and calendar events in a single, intuitive weekly view",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -1206,6 +1206,15 @@ h3 {
|
|||||||
color: var(--weekly-teal);
|
color: var(--weekly-teal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* URL innerhalb eines Aufgabentitels, siehe LinkifiedText.tsx */
|
||||||
|
.task-title-link {
|
||||||
|
color: var(--weekly-teal);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.task-title-link:hover {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
.subtask-edit-input {
|
.subtask-edit-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { faServer, faFolder, IconDefinition } from "@fortawesome/free-solid-svg-
|
|||||||
import MdiIcon from "@mdi/react";
|
import MdiIcon from "@mdi/react";
|
||||||
import { allIcons } from "./iconRegistry";
|
import { allIcons } from "./iconRegistry";
|
||||||
import { Task, KanbanStage, getPriorityBadge } from "./WeeklyView";
|
import { Task, KanbanStage, getPriorityBadge } from "./WeeklyView";
|
||||||
|
import { LinkifiedText } from "./weekly/LinkifiedText";
|
||||||
|
|
||||||
interface GridTaskBlockProps {
|
interface GridTaskBlockProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
@ -392,7 +393,7 @@ export function GridTaskBlock({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<span style={task.completed && showTaskCheckboxes ? { opacity: 0.5, flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" } : { flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" }}>
|
<span style={task.completed && showTaskCheckboxes ? { opacity: 0.5, flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" } : { flex: 1, display: "flex", alignItems: "center", flexWrap: "wrap", rowGap: "2px" }}>
|
||||||
<span style={{ marginRight: "4px" }}>{task.title}</span>
|
<span style={{ marginRight: "4px" }}><LinkifiedText text={task.title} /></span>
|
||||||
{/* Subtask indicator */}
|
{/* Subtask indicator */}
|
||||||
{task.subTasks && task.subTasks.length > 0 && (() => {
|
{task.subTasks && task.subTasks.length > 0 && (() => {
|
||||||
const completed = task.subTasks.filter(s => s.completed).length;
|
const completed = task.subTasks.filter(s => s.completed).length;
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
Link2,
|
Link2,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { LinkifiedText } from "./weekly/LinkifiedText";
|
||||||
|
|
||||||
export interface PriorityTask {
|
export interface PriorityTask {
|
||||||
id: string;
|
id: string;
|
||||||
@ -708,7 +709,7 @@ function TaskCard({ task, darkMode, cardBg, border, textPrimary, textSecondary,
|
|||||||
title={task.title}
|
title={task.title}
|
||||||
onDoubleClick={() => setEditingTitle(true)}
|
onDoubleClick={() => setEditingTitle(true)}
|
||||||
>
|
>
|
||||||
{task.title}
|
<LinkifiedText text={task.title} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{/* Action icons */}
|
{/* Action icons */}
|
||||||
|
|||||||
46
src/components/weekly/LinkifiedText.tsx
Normal file
46
src/components/weekly/LinkifiedText.tsx
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { splitTextAndUrls } from "@/lib/weekly/format";
|
||||||
|
|
||||||
|
interface LinkifiedTextProps {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zeigt einen Aufgabentitel an und macht darin enthaltene URLs anklickbar.
|
||||||
|
*
|
||||||
|
* Aufgabentitel liegen bisher als reiner Text in einem <span>, das selbst Teil des
|
||||||
|
* anklickbaren Bereichs zum Erledigt-Umschalten ist -- eine getippte URL im Titel war
|
||||||
|
* dadurch nie ein Link, sondern hat beim Antippen die Aufgabe durchgestrichen. Jedes
|
||||||
|
* erkannte URL-Segment bekommt hier ein eigenes <a>-Tag mit stopPropagation, damit ein
|
||||||
|
* Klick auf den Link tatsaechlich den Link oeffnet statt die Aufgabe umzuschalten.
|
||||||
|
*/
|
||||||
|
export function LinkifiedText({ text }: LinkifiedTextProps) {
|
||||||
|
const segments = splitTextAndUrls(text);
|
||||||
|
if (segments.length === 0) return null;
|
||||||
|
if (segments.length === 1 && segments[0].type === "text") {
|
||||||
|
// Haeufigster Fall (kein Link im Titel): unveraendert als reiner Text, kein
|
||||||
|
// zusaetzliches Wrapper-Element im DOM.
|
||||||
|
return <>{segments[0].value}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{segments.map((segment, i) =>
|
||||||
|
segment.type === "url" ? (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={segment.value.startsWith("http") ? segment.value : `https://${segment.value}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="task-title-link"
|
||||||
|
>
|
||||||
|
{segment.value}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<React.Fragment key={i}>{segment.value}</React.Fragment>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import { faServer } from "@fortawesome/free-solid-svg-icons";
|
|||||||
import type { Task, SomedayList, KanbanStage } from "@/lib/weekly/types";
|
import type { Task, SomedayList, KanbanStage } from "@/lib/weekly/types";
|
||||||
import { getPriorityBadge } from "./priority-badge";
|
import { getPriorityBadge } from "./priority-badge";
|
||||||
import { ProjectIcon } from "./ProjectIcon";
|
import { ProjectIcon } from "./ProjectIcon";
|
||||||
|
import { LinkifiedText } from "./LinkifiedText";
|
||||||
|
|
||||||
const RichTextEditor = dynamic(() => import("../RichTextEditor"), { ssr: false });
|
const RichTextEditor = dynamic(() => import("../RichTextEditor"), { ssr: false });
|
||||||
|
|
||||||
@ -393,7 +394,7 @@ export function TaskItem({
|
|||||||
<ProjectIcon icon={task.project.icon} size={13} color={task.project.color || "#888"} />
|
<ProjectIcon icon={task.project.icon} size={13} color={task.project.color || "#888"} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{task.title}
|
<LinkifiedText text={task.title} />
|
||||||
</span>
|
</span>
|
||||||
{(() => {
|
{(() => {
|
||||||
const provider = task.externalProvider
|
const provider = task.externalProvider
|
||||||
|
|||||||
@ -22,6 +22,60 @@ export const getSomedaySlotCount = (tasks: Task[]) => {
|
|||||||
return Math.max(DEFAULT_SOMEDAY_SLOT_COUNT, maxIdx + 2);
|
return Math.max(DEFAULT_SOMEDAY_SLOT_COUNT, maxIdx + 2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Textabschnitt: entweder normaler Text oder eine erkannte URL.
|
||||||
|
*
|
||||||
|
* Rein für die Erkennung/Aufteilung zuständig — nichts hier weiß etwas von React oder
|
||||||
|
* vom DOM. Das eigentliche Rendern (als `<a>`-Tag, mit `stopPropagation` gegen das
|
||||||
|
* Toggle-auf-Klick der umgebenden Aufgabe) übernimmt `LinkifiedText.tsx`.
|
||||||
|
*/
|
||||||
|
export interface TextSegment {
|
||||||
|
type: "text" | "url";
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// http(s)://... oder www.-Adressen, aber ohne einen abschließenden Satzzeichen-Rest
|
||||||
|
// (".", ",", ")", "!", "?") mitzureißen -- sonst würde "siehe https://x.de." den Punkt
|
||||||
|
// noch in den Link ziehen.
|
||||||
|
const URL_PATTERN = /(https?:\/\/[^\s<>"]+|www\.[^\s<>"]+)/gi;
|
||||||
|
const TRAILING_PUNCTUATION = /[.,!?;:)\]]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zerlegt einen Aufgabentitel in Text- und URL-Abschnitte, damit eine im Titel
|
||||||
|
* getippte Adresse (z. B. "Rechnung siehe https://example.com/invoice pruefen") als
|
||||||
|
* anklickbarer Link dargestellt werden kann, statt als reiner Text.
|
||||||
|
*/
|
||||||
|
export function splitTextAndUrls(text: string): TextSegment[] {
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
const segments: TextSegment[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
for (const match of text.matchAll(URL_PATTERN)) {
|
||||||
|
const start = match.index ?? 0;
|
||||||
|
let url = match[0];
|
||||||
|
|
||||||
|
// Satzzeichen, die grammatisch zum umgebenden Satz gehören, nicht zur Adresse.
|
||||||
|
const trailing = url.match(TRAILING_PUNCTUATION);
|
||||||
|
if (trailing) {
|
||||||
|
url = url.slice(0, url.length - trailing[0].length);
|
||||||
|
}
|
||||||
|
if (!url) continue;
|
||||||
|
|
||||||
|
if (start > lastIndex) {
|
||||||
|
segments.push({ type: "text", value: text.slice(lastIndex, start) });
|
||||||
|
}
|
||||||
|
segments.push({ type: "url", value: url });
|
||||||
|
lastIndex = start + url.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastIndex < text.length) {
|
||||||
|
segments.push({ type: "text", value: text.slice(lastIndex) });
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
export function invertColor(hex: string): string {
|
export function invertColor(hex: string): string {
|
||||||
if (!hex) return hex;
|
if (!hex) return hex;
|
||||||
let color = hex.startsWith("#") ? hex.slice(1) : hex;
|
let color = hex.startsWith("#") ? hex.slice(1) : hex;
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { test, describe } from "node:test";
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import {
|
import {
|
||||||
stripHtml, invertColor, getSomedaySlotCount, DEFAULT_SOMEDAY_SLOT_COUNT,
|
stripHtml, invertColor, getSomedaySlotCount, DEFAULT_SOMEDAY_SLOT_COUNT,
|
||||||
|
splitTextAndUrls,
|
||||||
} from "../../src/lib/weekly/format.ts";
|
} from "../../src/lib/weekly/format.ts";
|
||||||
import { isAllDayEvent } from "../../src/lib/weekly/date-utils.ts";
|
import { isAllDayEvent } from "../../src/lib/weekly/date-utils.ts";
|
||||||
import { clampViewDays, MAX_VIEW_DAYS } from "../../src/lib/weekly/device-settings.ts";
|
import { clampViewDays, MAX_VIEW_DAYS } from "../../src/lib/weekly/device-settings.ts";
|
||||||
@ -131,3 +132,73 @@ describe("clampViewDays", () => {
|
|||||||
assert.equal(clampViewDays(Infinity), 7);
|
assert.equal(clampViewDays(Infinity), 7);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URLs in Aufgabentiteln erkennen (damit sie als Link statt als reiner Text angezeigt
|
||||||
|
* werden können, siehe LinkifiedText.tsx). Erwartetes Verhalten: eine getippte Adresse
|
||||||
|
* im Titel wird als eigenes Segment erkannt, umgebender Text bleibt unverändert, und
|
||||||
|
* Satzzeichen, die grammatisch zum Satz gehören, werden nicht mit in den Link gezogen.
|
||||||
|
*/
|
||||||
|
describe("splitTextAndUrls", () => {
|
||||||
|
test("Titel ohne URL bleibt ein einzelnes Textsegment", () => {
|
||||||
|
assert.deepEqual(splitTextAndUrls("Einkaufen gehen"), [
|
||||||
|
{ type: "text", value: "Einkaufen gehen" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("leerer Titel ergibt keine Segmente", () => {
|
||||||
|
assert.deepEqual(splitTextAndUrls(""), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Titel, der nur aus einer URL besteht", () => {
|
||||||
|
assert.deepEqual(splitTextAndUrls("https://example.com"), [
|
||||||
|
{ type: "url", value: "https://example.com" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("URL mit Text davor und danach", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
splitTextAndUrls("Rechnung siehe https://example.com/invoice prüfen"),
|
||||||
|
[
|
||||||
|
{ type: "text", value: "Rechnung siehe " },
|
||||||
|
{ type: "url", value: "https://example.com/invoice" },
|
||||||
|
{ type: "text", value: " prüfen" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("www.-Adresse ohne Schema wird ebenfalls erkannt", () => {
|
||||||
|
assert.deepEqual(splitTextAndUrls("siehe www.example.com"), [
|
||||||
|
{ type: "text", value: "siehe " },
|
||||||
|
{ type: "url", value: "www.example.com" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("abschließender Satzpunkt gehört zum Text, nicht zur URL", () => {
|
||||||
|
const segments = splitTextAndUrls("Link: https://example.com.");
|
||||||
|
assert.deepEqual(segments, [
|
||||||
|
{ type: "text", value: "Link: " },
|
||||||
|
{ type: "url", value: "https://example.com" },
|
||||||
|
{ type: "text", value: "." },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("URL in Klammern: die schließende Klammer bleibt Text", () => {
|
||||||
|
assert.deepEqual(splitTextAndUrls("Details (https://example.com/x) nachlesen"), [
|
||||||
|
{ type: "text", value: "Details (" },
|
||||||
|
{ type: "url", value: "https://example.com/x" },
|
||||||
|
{ type: "text", value: ") nachlesen" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zwei URLs im selben Titel", () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
splitTextAndUrls("https://a.example.com und https://b.example.com"),
|
||||||
|
[
|
||||||
|
{ type: "url", value: "https://a.example.com" },
|
||||||
|
{ type: "text", value: " und " },
|
||||||
|
{ type: "url", value: "https://b.example.com" },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user