@djangocfg/widget-editor 0.1.1
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/LICENSE +21 -0
- package/README.md +40 -0
- package/package.json +121 -0
- package/src/blocks/BlockError.tsx +46 -0
- package/src/blocks/index.ts +34 -0
- package/src/blocks/schemas.ts +143 -0
- package/src/blocks/types.ts +76 -0
- package/src/chips/README.md +67 -0
- package/src/chips/UrlChip.tsx +105 -0
- package/src/chips/index.ts +20 -0
- package/src/index.ts +12 -0
- package/src/link-preview/LinkPreviewCard.tsx +178 -0
- package/src/link-preview/index.ts +12 -0
- package/src/link-preview/types.ts +44 -0
- package/src/markdown/MarkdownEditor.tsx +700 -0
- package/src/markdown/MentionList.tsx +106 -0
- package/src/markdown/README.md +269 -0
- package/src/markdown/chip/ChipNode.ts +526 -0
- package/src/markdown/chip/syncChips.ts +80 -0
- package/src/markdown/createMentionSuggestion.ts +131 -0
- package/src/markdown/filePath/FilePathChip.tsx +68 -0
- package/src/markdown/filePath/detect.ts +289 -0
- package/src/markdown/filePath/index.ts +15 -0
- package/src/markdown/index.ts +29 -0
- package/src/markdown/lazy.tsx +88 -0
- package/src/markdown/markdown-editor.stories.tsx +500 -0
- package/src/markdown/mentionPresets.ts +49 -0
- package/src/markdown/slash/SlashCommandNode.ts +162 -0
- package/src/markdown/slash/index.ts +4 -0
- package/src/markdown/slash/syncSlashNode.ts +97 -0
- package/src/markdown/slash/types.ts +14 -0
- package/src/markdown/styles.css +522 -0
- package/src/markdown/submitOnEnter.ts +83 -0
- package/src/markdown/types.ts +50 -0
- package/src/markdown/url/detect.ts +227 -0
- package/src/notion/BookmarkNode.ts +161 -0
- package/src/notion/BookmarkView.tsx +45 -0
- package/src/notion/CustomKeymap.ts +48 -0
- package/src/notion/LinkDialog.tsx +133 -0
- package/src/notion/MapEditLayer.tsx +199 -0
- package/src/notion/MapNode.ts +220 -0
- package/src/notion/MapView.tsx +103 -0
- package/src/notion/NotionEditor.tsx +309 -0
- package/src/notion/README.md +158 -0
- package/src/notion/SlashExtension.ts +32 -0
- package/src/notion/SlashList.tsx +136 -0
- package/src/notion/TaskItemView.tsx +41 -0
- package/src/notion/createSlashSuggestion.ts +121 -0
- package/src/notion/extensions.ts +120 -0
- package/src/notion/index.ts +1 -0
- package/src/notion/lazy.tsx +44 -0
- package/src/notion/notion-editor.stories.tsx +293 -0
- package/src/notion/slashItems.ts +178 -0
- package/src/notion/styles.css +503 -0
- package/src/notion/types.ts +37 -0
- package/src/tiptap/index.ts +10 -0
- package/src/tiptap/useNodeAttrs.ts +128 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rich-text editors and the pieces both are built from.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately NOT a barrel over everything: TipTap is sixteen packages and
|
|
5
|
+
* the two editors do not share a consumer. An app wanting the markdown field
|
|
6
|
+
* must not resolve the Notion editor's tables, drag handle and syntax
|
|
7
|
+
* highlighter behind it.
|
|
8
|
+
*
|
|
9
|
+
* Import a subpath: `@djangocfg/widget-editor/markdown`,
|
|
10
|
+
* `@djangocfg/widget-editor/notion`, `/blocks`, `/link-preview`, `/chips`.
|
|
11
|
+
*/
|
|
12
|
+
export type { MarkdownEditorProps } from './markdown';
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* LinkPreviewCard — a presentational URL-unfurl card (favicon + og:image +
|
|
5
|
+
* title / description / site name), Notion / Telegram style.
|
|
6
|
+
*
|
|
7
|
+
* It NEVER fetches the page itself — the browser can't read cross-origin
|
|
8
|
+
* meta tags (CORS). It either renders pre-resolved `data`, or calls the
|
|
9
|
+
* host-provided `resolver(url)` (a Wails/Go method, a Next.js API route)
|
|
10
|
+
* once on mount, showing a skeleton until it resolves. A `null` result
|
|
11
|
+
* (or a thrown resolver) renders nothing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useEffect, useRef, useState } from 'react';
|
|
15
|
+
|
|
16
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
17
|
+
|
|
18
|
+
import type { LinkPreviewData, ResolveLinkPreview } from './types';
|
|
19
|
+
|
|
20
|
+
export interface LinkPreviewCardProps {
|
|
21
|
+
url: string;
|
|
22
|
+
/** Pre-resolved metadata. When present, no resolver call is made. */
|
|
23
|
+
data?: LinkPreviewData;
|
|
24
|
+
/** Host resolver used when `data` is absent. */
|
|
25
|
+
resolver?: ResolveLinkPreview;
|
|
26
|
+
/** Layout — `compact` (default) is a tight horizontal card; `full` is a
|
|
27
|
+
* roomier card with a larger cover image. */
|
|
28
|
+
appearance?: 'compact' | 'full';
|
|
29
|
+
className?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Hostname for the fallback site label / favicon. */
|
|
33
|
+
function hostnameOf(url: string): string {
|
|
34
|
+
try {
|
|
35
|
+
return new URL(url).hostname.replace(/^www\./, '');
|
|
36
|
+
} catch {
|
|
37
|
+
return url;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type LoadState =
|
|
42
|
+
| { status: 'ready'; data: LinkPreviewData }
|
|
43
|
+
| { status: 'loading' }
|
|
44
|
+
| { status: 'empty' };
|
|
45
|
+
|
|
46
|
+
export function LinkPreviewCard({
|
|
47
|
+
url,
|
|
48
|
+
data,
|
|
49
|
+
resolver,
|
|
50
|
+
appearance = 'compact',
|
|
51
|
+
className,
|
|
52
|
+
}: LinkPreviewCardProps) {
|
|
53
|
+
// If data is supplied we're immediately ready; else loading (resolver) or
|
|
54
|
+
// empty (nothing to do).
|
|
55
|
+
const [state, setState] = useState<LoadState>(() =>
|
|
56
|
+
data ? { status: 'ready', data } : resolver ? { status: 'loading' } : { status: 'empty' },
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// Keep the latest resolver without re-running the effect on identity churn.
|
|
60
|
+
const resolverRef = useRef(resolver);
|
|
61
|
+
resolverRef.current = resolver;
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (data) {
|
|
65
|
+
setState({ status: 'ready', data });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const resolve = resolverRef.current;
|
|
69
|
+
if (!resolve) {
|
|
70
|
+
setState({ status: 'empty' });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let alive = true;
|
|
75
|
+
const controller = new AbortController();
|
|
76
|
+
setState({ status: 'loading' });
|
|
77
|
+
|
|
78
|
+
resolve(url, controller.signal)
|
|
79
|
+
.then((result) => {
|
|
80
|
+
if (!alive) return;
|
|
81
|
+
setState(result ? { status: 'ready', data: result } : { status: 'empty' });
|
|
82
|
+
})
|
|
83
|
+
.catch(() => {
|
|
84
|
+
if (alive) setState({ status: 'empty' });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return () => {
|
|
88
|
+
alive = false;
|
|
89
|
+
controller.abort();
|
|
90
|
+
};
|
|
91
|
+
}, [url, data]);
|
|
92
|
+
|
|
93
|
+
if (state.status === 'empty') return null;
|
|
94
|
+
|
|
95
|
+
const host = hostnameOf(url);
|
|
96
|
+
|
|
97
|
+
// Shared frame — matches the calm media-card surface used elsewhere.
|
|
98
|
+
const frame = cn(
|
|
99
|
+
'block w-full overflow-hidden rounded-2xl border border-border bg-card shadow-sm',
|
|
100
|
+
'transition-colors hover:bg-accent/40',
|
|
101
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
102
|
+
className,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (state.status === 'loading') {
|
|
106
|
+
return (
|
|
107
|
+
<div className={cn(frame, 'pointer-events-none')} aria-busy="true">
|
|
108
|
+
<div className="flex items-center gap-3 p-3">
|
|
109
|
+
<div className="size-9 shrink-0 animate-pulse rounded-md bg-muted" />
|
|
110
|
+
<div className="min-w-0 flex-1 space-y-2">
|
|
111
|
+
<div className="h-3 w-2/3 animate-pulse rounded bg-muted" />
|
|
112
|
+
<div className="h-2.5 w-1/3 animate-pulse rounded bg-muted" />
|
|
113
|
+
</div>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const meta = state.data;
|
|
120
|
+
const title = meta.title || host;
|
|
121
|
+
const siteName = meta.siteName || host;
|
|
122
|
+
const isFull = appearance === 'full';
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<a href={url} target="_blank" rel="noopener noreferrer" className={frame}>
|
|
126
|
+
{/* Cover image (full appearance, when present) — top banner. */}
|
|
127
|
+
{isFull && meta.image ? (
|
|
128
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
129
|
+
<img
|
|
130
|
+
src={meta.image}
|
|
131
|
+
alt=""
|
|
132
|
+
loading="lazy"
|
|
133
|
+
// Fixed banner height (not just max-h) so `object-cover` actually
|
|
134
|
+
// crops to fill — with only `max-h` the image keeps its aspect and
|
|
135
|
+
// letterboxes. `h-44` is a sensible cover banner.
|
|
136
|
+
className="h-44 w-full border-b border-border object-cover"
|
|
137
|
+
/>
|
|
138
|
+
) : null}
|
|
139
|
+
|
|
140
|
+
<div className="flex items-stretch gap-3 p-3">
|
|
141
|
+
<div className="min-w-0 flex-1">
|
|
142
|
+
{/* Site row — favicon + site name. */}
|
|
143
|
+
<div className="mb-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
144
|
+
{meta.favicon ? (
|
|
145
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
146
|
+
<img
|
|
147
|
+
src={meta.favicon}
|
|
148
|
+
alt=""
|
|
149
|
+
loading="lazy"
|
|
150
|
+
className="size-4 shrink-0 rounded-sm"
|
|
151
|
+
/>
|
|
152
|
+
) : null}
|
|
153
|
+
<span className="truncate">{siteName}</span>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div className="line-clamp-2 text-sm font-medium text-foreground">{title}</div>
|
|
157
|
+
|
|
158
|
+
{meta.description ? (
|
|
159
|
+
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">
|
|
160
|
+
{meta.description}
|
|
161
|
+
</div>
|
|
162
|
+
) : null}
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
{/* Compact thumbnail (compact appearance, when present) — side. */}
|
|
166
|
+
{!isFull && meta.image ? (
|
|
167
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
168
|
+
<img
|
|
169
|
+
src={meta.image}
|
|
170
|
+
alt=""
|
|
171
|
+
loading="lazy"
|
|
172
|
+
className="size-16 shrink-0 self-center rounded-lg object-cover"
|
|
173
|
+
/>
|
|
174
|
+
) : null}
|
|
175
|
+
</div>
|
|
176
|
+
</a>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Surface-agnostic link-preview (URL unfurl) primitives.
|
|
3
|
+
*
|
|
4
|
+
* `LinkPreviewCard` is a pure presentational card (favicon + og:image +
|
|
5
|
+
* title / description / site name) that NEVER fetches the page — it renders
|
|
6
|
+
* pre-resolved `data` or calls a host-provided `resolver(url)`. It is shared
|
|
7
|
+
* across every surface: the chat `LinkBlock` and the NotionEditor `bookmark`
|
|
8
|
+
* node both import it and supply their own resolver. No chat coupling.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { LinkPreviewCard, type LinkPreviewCardProps } from './LinkPreviewCard';
|
|
12
|
+
export type { LinkPreviewData, ResolveLinkPreview } from './types';
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Link-preview (URL unfurl) types — the data contract between the host's
|
|
3
|
+
* metadata fetcher and the presentational card.
|
|
4
|
+
*
|
|
5
|
+
* The browser CANNOT fetch arbitrary URLs to scrape OpenGraph/meta tags
|
|
6
|
+
* (CORS blocks cross-origin HTML reads), so resolution is ALWAYS a host
|
|
7
|
+
* concern: a server route (Next.js) or a native backend (Wails/Go). The
|
|
8
|
+
* UI never fetches — it receives already-resolved metadata, or a resolver
|
|
9
|
+
* callback the host implements.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Resolved metadata for one URL — the unfurl result. All fields except
|
|
14
|
+
* `url` are optional: a card renders from whatever subset is available
|
|
15
|
+
* (favicon + title is enough; a bare `url` falls back to the hostname).
|
|
16
|
+
* Plain JSON so it survives history persistence + SSE transport.
|
|
17
|
+
*/
|
|
18
|
+
export interface LinkPreviewData {
|
|
19
|
+
/** The canonical/resolved URL the card links to. */
|
|
20
|
+
url: string;
|
|
21
|
+
/** og:title → twitter:title → <title>. */
|
|
22
|
+
title?: string;
|
|
23
|
+
/** og:description → twitter:description → <meta name=description>. */
|
|
24
|
+
description?: string;
|
|
25
|
+
/** og:image → twitter:image (absolute URL; host resolves relative ones). */
|
|
26
|
+
image?: string;
|
|
27
|
+
/** Favicon URL (host resolves <link rel=icon> / /favicon.ico). */
|
|
28
|
+
favicon?: string;
|
|
29
|
+
/** og:site_name → hostname. */
|
|
30
|
+
siteName?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Host-provided resolver. Given a URL, returns its preview metadata, or
|
|
35
|
+
* `null` when nothing useful could be fetched (so the card is skipped).
|
|
36
|
+
* Implementations:
|
|
37
|
+
* - Wails/Go desktop → a bound `FetchLinkMetadata(url)` service method.
|
|
38
|
+
* - Next.js web → a `/api/unfurl?url=` route.
|
|
39
|
+
* The host owns caching, SSRF protection, timeouts and bot User-Agents.
|
|
40
|
+
*/
|
|
41
|
+
export type ResolveLinkPreview = (
|
|
42
|
+
url: string,
|
|
43
|
+
signal?: AbortSignal,
|
|
44
|
+
) => Promise<LinkPreviewData | null>;
|