#152: Datums-Marker >> (Zieldatum) / << (Startdatum)
Neuer Inline-Atom date_marker {kind: due|start, date: ISO}. Markdown
kanonisch ISO (>>2026-12-31), Eingabe-Kulanz dd.mm.yyyy; Block-Guard
vor blockquote hält zeilenführende >>Daten aus dem Zitat-Parser;
ungültige Kalenderdaten bleiben Text. Editor: InputRule beim Tippen
(+Leerzeichen), Anzeige per Intl.DateTimeFormat in Nutzersprache,
Überfällig-Färbung. Die User.locale-Verdrahtung existierte bereits
(auth-context, #17) — keine Änderung nötig. 6 Unit-Tests inkl.
Task-Listen-Zeile mit Marker und Mention.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0155v2aT8AG1kZDQEZiCLBWC
This commit is contained in:
parent
7471fc70f7
commit
92a3b2f6d5
@ -7,6 +7,7 @@ import { BulletList, ListItem, OrderedList, TaskList } from './nodes/lists';
|
|||||||
import { PluginBlock } from './nodes/plugin-block';
|
import { PluginBlock } from './nodes/plugin-block';
|
||||||
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
import { Table, TableCell, TableHeader, TableRow } from './nodes/table';
|
||||||
import { TaskItem } from './nodes/task-item';
|
import { TaskItem } from './nodes/task-item';
|
||||||
|
import { DateMarker } from './nodes/date-marker';
|
||||||
import { Mention } from './nodes/mention';
|
import { Mention } from './nodes/mention';
|
||||||
import { Transclusion } from './nodes/transclusion';
|
import { Transclusion } from './nodes/transclusion';
|
||||||
import { Wikilink } from './nodes/wikilink';
|
import { Wikilink } from './nodes/wikilink';
|
||||||
@ -46,6 +47,7 @@ export const documentExtensions: AnyExtension[] = [
|
|||||||
PluginBlock,
|
PluginBlock,
|
||||||
Wikilink,
|
Wikilink,
|
||||||
Mention,
|
Mention,
|
||||||
|
DateMarker,
|
||||||
Transclusion,
|
Transclusion,
|
||||||
Table,
|
Table,
|
||||||
TableRow,
|
TableRow,
|
||||||
|
|||||||
91
apps/web/src/editor/nodes/date-marker.tsx
Normal file
91
apps/web/src/editor/nodes/date-marker.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { InputRule, Node } from '@tiptap/core';
|
||||||
|
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
|
||||||
|
import type { NodeViewProps } from '@tiptap/react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { attributesFromSpec, nodeSpec } from '../spec-utils';
|
||||||
|
|
||||||
|
/** `>>`/`<<` + ISO or `dd.mm.yyyy`, completed by a space (the trigger). */
|
||||||
|
const INPUT_PATTERN =
|
||||||
|
/(?:^|\s)([<>])\1\s?(?:(\d{4})-(\d{2})-(\d{2})|(\d{1,2})\.(\d{1,2})\.(\d{4}))\s$/;
|
||||||
|
|
||||||
|
function isoDateOf(match: RegExpMatchArray): string | null {
|
||||||
|
const [year, month, day] = match[2]
|
||||||
|
? [Number(match[2]), Number(match[3]), Number(match[4])]
|
||||||
|
: [Number(match[7]), Number(match[6]), Number(match[5])];
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
const valid =
|
||||||
|
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||||
|
if (!valid) return null;
|
||||||
|
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||||
|
return `${year}-${pad(month)}-${pad(day)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders a date marker (issue #152): `»` = due date (`>>`), `«` = start
|
||||||
|
* date (`<<`), formatted per the viewer's language. A due date in the past
|
||||||
|
* gets an "overdue" tint. The stored attr is always canonical ISO.
|
||||||
|
*/
|
||||||
|
function DateMarkerView({ node }: NodeViewProps): React.JSX.Element {
|
||||||
|
const { t, i18n } = useTranslation('editor');
|
||||||
|
const kind = node.attrs.kind as 'due' | 'start';
|
||||||
|
const iso = node.attrs.date as string;
|
||||||
|
|
||||||
|
const formatted = new Intl.DateTimeFormat(i18n.language, { dateStyle: 'medium' }).format(
|
||||||
|
new Date(`${iso}T00:00:00`),
|
||||||
|
);
|
||||||
|
const overdue = kind === 'due' && iso < new Date().toISOString().slice(0, 10);
|
||||||
|
const classes = ['dt-date', `dt-date--${kind}`];
|
||||||
|
if (overdue) classes.push('dt-date--overdue');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NodeViewWrapper as="span" className="dt-date-nodeview">
|
||||||
|
<span
|
||||||
|
className={classes.join(' ')}
|
||||||
|
title={t(kind === 'due' ? 'dateMarker.due' : 'dateMarker.start')}
|
||||||
|
contentEditable={false}
|
||||||
|
>
|
||||||
|
{kind === 'due' ? '»' : '«'} {formatted}
|
||||||
|
</span>
|
||||||
|
</NodeViewWrapper>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateMarkerSpec = nodeSpec('date_marker');
|
||||||
|
export const DateMarker = Node.create({
|
||||||
|
name: 'date_marker',
|
||||||
|
group: dateMarkerSpec.group,
|
||||||
|
inline: dateMarkerSpec.inline,
|
||||||
|
atom: dateMarkerSpec.atom,
|
||||||
|
addAttributes() {
|
||||||
|
return attributesFromSpec(dateMarkerSpec);
|
||||||
|
},
|
||||||
|
parseHTML: () => dateMarkerSpec.parseDOM,
|
||||||
|
renderHTML: ({ node }) => dateMarkerSpec.toDOM!(node),
|
||||||
|
addInputRules() {
|
||||||
|
return [
|
||||||
|
new InputRule({
|
||||||
|
find: INPUT_PATTERN,
|
||||||
|
handler: ({ range, match, chain }) => {
|
||||||
|
const iso = isoDateOf(match);
|
||||||
|
if (!iso) return;
|
||||||
|
// Keep a leading boundary character (space/line start) intact.
|
||||||
|
const full = match[0];
|
||||||
|
const markerStart = range.from + (full.length - full.trimStart().length);
|
||||||
|
chain()
|
||||||
|
.insertContentAt({ from: markerStart, to: range.to }, [
|
||||||
|
{
|
||||||
|
type: 'date_marker',
|
||||||
|
attrs: { kind: match[1] === '>' ? 'due' : 'start', date: iso },
|
||||||
|
},
|
||||||
|
{ type: 'text', text: ' ' },
|
||||||
|
])
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
addNodeView() {
|
||||||
|
return ReactNodeViewRenderer(DateMarkerView);
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -3824,3 +3824,22 @@ ul[data-type='task_list'] li p:last-of-type {
|
|||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
text-decoration: line-through;
|
text-decoration: line-through;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Date marker chip (issue #152): » = due date, « = start date. */
|
||||||
|
.dt-date {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0 0.35em;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--color-bg-subtle);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-date--due {
|
||||||
|
color: var(--color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dt-date--overdue {
|
||||||
|
color: #b91c1c;
|
||||||
|
}
|
||||||
|
|||||||
@ -185,5 +185,9 @@
|
|||||||
"mention": {
|
"mention": {
|
||||||
"unresolved": "Unbekannter Nutzer",
|
"unresolved": "Unbekannter Nutzer",
|
||||||
"suggestLabel": "Nutzer-Vorschläge"
|
"suggestLabel": "Nutzer-Vorschläge"
|
||||||
|
},
|
||||||
|
"dateMarker": {
|
||||||
|
"due": "Zieldatum",
|
||||||
|
"start": "Startdatum"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -185,5 +185,9 @@
|
|||||||
"mention": {
|
"mention": {
|
||||||
"unresolved": "Unknown user",
|
"unresolved": "Unknown user",
|
||||||
"suggestLabel": "User suggestions"
|
"suggestLabel": "User suggestions"
|
||||||
|
},
|
||||||
|
"dateMarker": {
|
||||||
|
"due": "Due date",
|
||||||
|
"start": "Start date"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
73
packages/shared/src/editor-schema/date-marker.test.ts
Normal file
73
packages/shared/src/editor-schema/date-marker.test.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { Node } from 'prosemirror-model';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { docToHtml } from './html';
|
||||||
|
import { docToMarkdown, markdownToDoc } from './markdown';
|
||||||
|
import { docToPlainText } from './plain-text';
|
||||||
|
|
||||||
|
function markers(doc: Node): { kind: string; date: string }[] {
|
||||||
|
const found: { kind: string; date: string }[] = [];
|
||||||
|
doc.descendants((node) => {
|
||||||
|
if (node.type.name === 'date_marker') {
|
||||||
|
found.push({ kind: node.attrs.kind as string, date: node.attrs.date as string });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('date markers >>/<< (issue #152)', () => {
|
||||||
|
it('parses ISO due and start dates and round-trips canonically', () => {
|
||||||
|
const doc = markdownToDoc('Projekt <<2026-07-01 bis >>2026-12-31 fertig.');
|
||||||
|
expect(markers(doc)).toEqual([
|
||||||
|
{ kind: 'start', date: '2026-07-01' },
|
||||||
|
{ kind: 'due', date: '2026-12-31' },
|
||||||
|
]);
|
||||||
|
const markdown = docToMarkdown(doc);
|
||||||
|
expect(markdown).toContain('<<2026-07-01');
|
||||||
|
expect(markdown).toContain('>>2026-12-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts dd.mm.yyyy as input lenience but serializes ISO', () => {
|
||||||
|
const doc = markdownToDoc('Zieltermin >>31.12.2026 bitte.');
|
||||||
|
expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-12-31' }]);
|
||||||
|
expect(docToMarkdown(doc)).toContain('>>2026-12-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a line-leading >>date out of blockquote parsing', () => {
|
||||||
|
const doc = markdownToDoc('>>2026-12-31 ist die Deadline.');
|
||||||
|
expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-12-31' }]);
|
||||||
|
let quotes = 0;
|
||||||
|
doc.descendants((node) => {
|
||||||
|
if (node.type.name === 'blockquote') quotes += 1;
|
||||||
|
});
|
||||||
|
expect(quotes).toBe(0);
|
||||||
|
// A real blockquote still works.
|
||||||
|
const quote = markdownToDoc('> ein Zitat');
|
||||||
|
let realQuotes = 0;
|
||||||
|
quote.descendants((node) => {
|
||||||
|
if (node.type.name === 'blockquote') realQuotes += 1;
|
||||||
|
});
|
||||||
|
expect(realQuotes).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves invalid calendar dates as plain text', () => {
|
||||||
|
const doc = markdownToDoc('Kaputt: >>31.02.2026 bleibt Text.');
|
||||||
|
expect(markers(doc)).toEqual([]);
|
||||||
|
expect(docToMarkdown(doc)).toContain('31.02.2026');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders HTML with kind, ISO date, and shows up in plain text', () => {
|
||||||
|
const doc = markdownToDoc('Bis >>2026-12-31.');
|
||||||
|
const html = docToHtml(doc);
|
||||||
|
expect(html).toContain('data-date-marker="due"');
|
||||||
|
expect(html).toContain('data-date="2026-12-31"');
|
||||||
|
expect(docToPlainText(doc)).toContain('2026-12-31');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('works inside task list lines', () => {
|
||||||
|
const doc = markdownToDoc('- [ ] Bühne buchen >>2026-08-01 @nadia');
|
||||||
|
expect(markers(doc)).toEqual([{ kind: 'due', date: '2026-08-01' }]);
|
||||||
|
const markdown = docToMarkdown(doc);
|
||||||
|
expect(markdown).toContain('- [ ] Bühne buchen >>2026-08-01 @nadia');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -69,6 +69,14 @@ function renderInline(node: Node): string {
|
|||||||
const text = escapeHtml(display ?? (child.attrs.targetSlug as string));
|
const text = escapeHtml(display ?? (child.attrs.targetSlug as string));
|
||||||
const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : '';
|
const displayAttr = display ? ` data-display="${escapeHtml(display)}"` : '';
|
||||||
out += `<a class="wikilink" href="${slug}" data-wikilink="${slug}"${displayAttr}>${text}</a>`;
|
out += `<a class="wikilink" href="${slug}" data-wikilink="${slug}"${displayAttr}>${text}</a>`;
|
||||||
|
} else if (child.type.name === 'date_marker') {
|
||||||
|
// A date marker (issue #152): static HTML shows the unambiguous ISO
|
||||||
|
// date; locale-aware formatting happens where the viewer is known.
|
||||||
|
const kind = escapeHtml(child.attrs.kind as string);
|
||||||
|
const date = escapeHtml(child.attrs.date as string);
|
||||||
|
out +=
|
||||||
|
`<span class="dt-date dt-date--${kind}" data-date-marker="${kind}"` +
|
||||||
|
` data-date="${date}">${kind === 'due' ? '»' : '«'} ${date}</span>`;
|
||||||
} else if (child.type.name === 'mention') {
|
} else if (child.type.name === 'mention') {
|
||||||
// A user mention (issue #150): static HTML shows the @username; the
|
// A user mention (issue #150): static HTML shows the @username; the
|
||||||
// stable user id rides along for consumers that can resolve it.
|
// stable user id rides along for consumers that can resolve it.
|
||||||
|
|||||||
@ -192,6 +192,82 @@ function wikilinkRule(state: StateInline, silent: boolean): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `>>`/`<<` + ISO or `dd.mm.yyyy` date (issue #152). */
|
||||||
|
const DATE_MARKER = /^([<>])\1\s?(?:(\d{4})-(\d{2})-(\d{2})|(\d{1,2})\.(\d{1,2})\.(\d{4}))/;
|
||||||
|
|
||||||
|
/** Canonical `YYYY-MM-DD` from the regex groups, or null for a non-date. */
|
||||||
|
function isoDateOf(match: RegExpExecArray): string | null {
|
||||||
|
const [year, month, day] = match[2]
|
||||||
|
? [Number(match[2]), Number(match[3]), Number(match[4])]
|
||||||
|
: [Number(match[7]), Number(match[6]), Number(match[5])];
|
||||||
|
const date = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
const valid =
|
||||||
|
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
|
||||||
|
if (!valid) return null;
|
||||||
|
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||||
|
return `${year}-${pad(month)}-${pad(day)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** markdown-it inline rule for date markers (issue #152): `>>` = due date,
|
||||||
|
* `<<` = start date; ISO is canonical, `dd.mm.yyyy` is accepted as input
|
||||||
|
* lenience. An invalid calendar date stays plain text. */
|
||||||
|
function dateMarkerRule(state: StateInline, silent: boolean): boolean {
|
||||||
|
const match = DATE_MARKER.exec(state.src.slice(state.pos));
|
||||||
|
if (!match) return false;
|
||||||
|
const iso = isoDateOf(match);
|
||||||
|
if (!iso) return false;
|
||||||
|
if (!silent) {
|
||||||
|
const token = state.push('date_marker', '', 0);
|
||||||
|
token.attrs = [
|
||||||
|
['kind', match[1] === '>' ? 'due' : 'start'],
|
||||||
|
['date', iso],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
state.pos += match[0].length;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Block guard (issue #152): a line starting with `>>2026-…` is a paragraph
|
||||||
|
* with a due-date marker, not a nested blockquote — registered before
|
||||||
|
* `blockquote` so markdown-it never sees the `>` as a quote. */
|
||||||
|
function dateLineRule(
|
||||||
|
state: StateBlock,
|
||||||
|
startLine: number,
|
||||||
|
endLine: number,
|
||||||
|
silent: boolean,
|
||||||
|
): boolean {
|
||||||
|
const start = state.bMarks[startLine]! + state.tShift[startLine]!;
|
||||||
|
const max = state.eMarks[startLine]!;
|
||||||
|
const match = DATE_MARKER.exec(state.src.slice(start, max));
|
||||||
|
if (!match || !isoDateOf(match)) return false;
|
||||||
|
if (silent) return true;
|
||||||
|
// Consume the paragraph like markdown-it's own paragraph rule would.
|
||||||
|
const terminatorRules = state.md.block.ruler.getRules('paragraph');
|
||||||
|
let nextLine = startLine + 1;
|
||||||
|
for (; nextLine < endLine && !state.isEmpty(nextLine); nextLine += 1) {
|
||||||
|
if (state.sCount[nextLine]! - state.blkIndent > 3) continue;
|
||||||
|
if (state.sCount[nextLine]! < 0) continue;
|
||||||
|
let terminate = false;
|
||||||
|
for (const rule of terminatorRules) {
|
||||||
|
if (rule(state, nextLine, endLine, true)) {
|
||||||
|
terminate = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (terminate) break;
|
||||||
|
}
|
||||||
|
const content = state.getLines(startLine, nextLine, state.blkIndent, false).trim();
|
||||||
|
const open = state.push('paragraph_open', 'p', 1);
|
||||||
|
open.map = [startLine, nextLine];
|
||||||
|
const inline = state.push('inline', '', 0);
|
||||||
|
inline.content = content;
|
||||||
|
inline.map = [startLine, nextLine];
|
||||||
|
inline.children = [];
|
||||||
|
state.push('paragraph_close', 'p', -1);
|
||||||
|
state.line = nextLine;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Username shape after the `@` (mirrors the signup `usernameSchema`). */
|
/** Username shape after the `@` (mirrors the signup `usernameSchema`). */
|
||||||
const MENTION_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
|
const MENTION_NAME = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?/i;
|
||||||
|
|
||||||
@ -308,6 +384,10 @@ function createTokenizer(): MarkdownIt {
|
|||||||
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
|
md.inline.ruler.before('link', 'wikilink', wikilinkRule);
|
||||||
// `@username` mentions (issue #150).
|
// `@username` mentions (issue #150).
|
||||||
md.inline.ruler.before('link', 'mention', mentionRule);
|
md.inline.ruler.before('link', 'mention', mentionRule);
|
||||||
|
// `>>`/`<<` date markers (issue #152) …
|
||||||
|
md.inline.ruler.before('link', 'date_marker', dateMarkerRule);
|
||||||
|
// … and the guard that keeps a line-leading `>>date` out of blockquote.
|
||||||
|
md.block.ruler.before('blockquote', 'date_line', dateLineRule);
|
||||||
// Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads
|
// Run before `paragraph` so a lone `![[slug]]` line embeds rather than reads
|
||||||
// as plain text (issue #135).
|
// as plain text (issue #135).
|
||||||
md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
|
md.block.ruler.before('paragraph', 'transclusion', transclusionRule);
|
||||||
@ -383,6 +463,13 @@ const markdownParser = new MarkdownParser(editorSchema, createTokenizer(), {
|
|||||||
userId: tok.attrGet('userId') ?? '',
|
userId: tok.attrGet('userId') ?? '',
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
date_marker: {
|
||||||
|
node: 'date_marker',
|
||||||
|
getAttrs: (tok) => ({
|
||||||
|
kind: tok.attrGet('kind') ?? 'due',
|
||||||
|
date: tok.attrGet('date') ?? '',
|
||||||
|
}),
|
||||||
|
},
|
||||||
transclusion: {
|
transclusion: {
|
||||||
node: 'transclusion',
|
node: 'transclusion',
|
||||||
getAttrs: (tok) => ({
|
getAttrs: (tok) => ({
|
||||||
@ -517,6 +604,10 @@ const markdownSerializer = new MarkdownSerializer(
|
|||||||
mention(state, node) {
|
mention(state, node) {
|
||||||
state.write(`@${node.attrs.username as string}`);
|
state.write(`@${node.attrs.username as string}`);
|
||||||
},
|
},
|
||||||
|
date_marker(state, node) {
|
||||||
|
const prefix = node.attrs.kind === 'due' ? '>>' : '<<';
|
||||||
|
state.write(`${prefix}${node.attrs.date as string}`);
|
||||||
|
},
|
||||||
transclusion(state, node) {
|
transclusion(state, node) {
|
||||||
const slug = node.attrs.targetSlug as string;
|
const slug = node.attrs.targetSlug as string;
|
||||||
const display = node.attrs.displayText as string | null;
|
const display = node.attrs.displayText as string | null;
|
||||||
|
|||||||
@ -11,6 +11,8 @@ export function docToPlainText(doc: Node): string {
|
|||||||
}
|
}
|
||||||
// A mention reads as `@username` (issue #150), so search finds it.
|
// A mention reads as `@username` (issue #150), so search finds it.
|
||||||
if (leaf.type.name === 'mention') return `@${leaf.attrs.username as string}`;
|
if (leaf.type.name === 'mention') return `@${leaf.attrs.username as string}`;
|
||||||
|
// A date marker contributes its ISO date (issue #152).
|
||||||
|
if (leaf.type.name === 'date_marker') return leaf.attrs.date as string;
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
return text.replace(/\n{3,}/g, '\n\n').trim();
|
return text.replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
|||||||
@ -256,6 +256,38 @@ export const editorSchema = new Schema({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// `>>2026-12-31` / `<<2026-07-01` date marker (issue #152). An inline
|
||||||
|
// atom carrying a kind ('due' for `>>`, 'start' for `<<`) and the date as
|
||||||
|
// a canonical ISO `YYYY-MM-DD` string; locale-aware formatting happens in
|
||||||
|
// the editor, where the viewer's language is known.
|
||||||
|
date_marker: {
|
||||||
|
group: 'inline',
|
||||||
|
inline: true,
|
||||||
|
atom: true,
|
||||||
|
attrs: {
|
||||||
|
kind: { validate: 'string' },
|
||||||
|
date: { validate: 'string' },
|
||||||
|
},
|
||||||
|
parseDOM: [
|
||||||
|
{
|
||||||
|
tag: 'span[data-date-marker]',
|
||||||
|
getAttrs: (dom) => ({
|
||||||
|
kind: dom.getAttribute('data-date-marker'),
|
||||||
|
date: dom.getAttribute('data-date'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toDOM: (node) => {
|
||||||
|
const kind = node.attrs.kind as string;
|
||||||
|
const date = node.attrs.date as string;
|
||||||
|
return [
|
||||||
|
'span',
|
||||||
|
{ 'data-date-marker': kind, 'data-date': date, class: `dt-date dt-date--${kind}` },
|
||||||
|
`${kind === 'due' ? '»' : '«'} ${date}`,
|
||||||
|
];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// `@username` user mention (issue #150). An inline atom carrying the
|
// `@username` user mention (issue #150). An inline atom carrying the
|
||||||
// mentioned user's stable id plus the username as serialization/display
|
// mentioned user's stable id plus the username as serialization/display
|
||||||
// fallback. Live display-name resolution happens in the editor where a
|
// fallback. Live display-name resolution happens in the editor where a
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user