testomatio-editor-blocks 0.1.0 → 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.
@@ -0,0 +1,130 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export type StepSuggestion = {
4
+ id: string;
5
+ title: string;
6
+ description?: string | null;
7
+ kind?: string | null;
8
+ usageCount?: number | null;
9
+ commentsCount?: number | null;
10
+ isSnippet?: boolean | null;
11
+ labels?: string[];
12
+ keywords?: string[];
13
+ };
14
+
15
+ export type StepJsonApiAttributes = {
16
+ title?: string | null;
17
+ description?: string | null;
18
+ kind?: string | null;
19
+ keywords?: string[] | null;
20
+ labels?: string[] | null;
21
+ "usage-count"?: number | string | null;
22
+ "comments-count"?: number | string | null;
23
+ "is-snippet"?: boolean | null;
24
+ };
25
+
26
+ export type StepJsonApiResource = {
27
+ id?: string | number | null;
28
+ type?: string | null;
29
+ attributes?: StepJsonApiAttributes | null;
30
+ };
31
+
32
+ export type StepJsonApiDocument = {
33
+ data?: StepJsonApiResource[] | null;
34
+ };
35
+
36
+ export type StepSuggestionsFetcher = () => Promise<StepInput> | StepInput;
37
+
38
+ type StepInput = StepSuggestion[] | StepJsonApiDocument | StepJsonApiResource[] | null | undefined;
39
+
40
+ let globalFetcher: StepSuggestionsFetcher | null = null;
41
+ let cachedSuggestions: StepSuggestion[] = [];
42
+
43
+ export function setGlobalStepSuggestionsFetcher(fetcher: StepSuggestionsFetcher | null) {
44
+ globalFetcher = fetcher;
45
+ cachedSuggestions = [];
46
+ }
47
+
48
+ export function useStepAutocomplete(): StepSuggestion[] {
49
+ const [suggestions, setSuggestions] = useState<StepSuggestion[]>(cachedSuggestions);
50
+
51
+ useEffect(() => {
52
+ if (suggestions.length > 0) {
53
+ return;
54
+ }
55
+ if (!globalFetcher) {
56
+ return;
57
+ }
58
+
59
+ let cancelled = false;
60
+ Promise.resolve(globalFetcher())
61
+ .then((result) => normalizeStepSuggestions(result))
62
+ .then((items) => {
63
+ if (cancelled) return;
64
+ cachedSuggestions = items;
65
+ setSuggestions(items);
66
+ })
67
+ .catch((error) => console.error("Failed to fetch step suggestions", error));
68
+
69
+ return () => {
70
+ cancelled = true;
71
+ };
72
+ }, [suggestions.length]);
73
+
74
+ return suggestions;
75
+ }
76
+
77
+ export function parseStepsFromJsonApi(
78
+ document: StepJsonApiDocument | StepJsonApiResource[] | null | undefined,
79
+ ): StepSuggestion[] {
80
+ const resources = Array.isArray(document) ? document : document?.data;
81
+ if (!Array.isArray(resources) || resources.length === 0) {
82
+ return [];
83
+ }
84
+
85
+ return resources
86
+ .map((resource) => normalizeJsonApiResource(resource))
87
+ .filter((value): value is StepSuggestion => Boolean(value));
88
+ }
89
+
90
+ function normalizeStepSuggestions(steps?: StepInput): StepSuggestion[] {
91
+ if (!steps) return [];
92
+
93
+ if (Array.isArray(steps)) {
94
+ if (steps.length === 0) return [];
95
+ if (isStepSuggestionArray(steps)) return steps;
96
+ return parseStepsFromJsonApi(steps);
97
+ }
98
+
99
+ return parseStepsFromJsonApi(steps);
100
+ }
101
+
102
+ function normalizeJsonApiResource(resource: StepJsonApiResource | null | undefined): StepSuggestion | null {
103
+ if (!resource) return null;
104
+ const attrs = resource.attributes;
105
+ const id = resource.id;
106
+ const title = attrs?.title ?? "";
107
+ if (!id || !title) return null;
108
+
109
+ return {
110
+ id: String(id),
111
+ title: String(title),
112
+ description: attrs?.description ?? null,
113
+ kind: attrs?.kind ?? null,
114
+ usageCount: coerceNumber(attrs?.["usage-count"]),
115
+ commentsCount: coerceNumber(attrs?.["comments-count"]),
116
+ isSnippet: attrs?.["is-snippet"] ?? null,
117
+ labels: attrs?.labels ?? [],
118
+ keywords: attrs?.keywords ?? [],
119
+ };
120
+ }
121
+
122
+ function isStepSuggestionArray(value: StepInput): value is StepSuggestion[] {
123
+ return Array.isArray(value) && value.length > 0 && typeof (value[0] as StepSuggestion | any)?.title === "string";
124
+ }
125
+
126
+ function coerceNumber(value: string | number | null | undefined): number | null {
127
+ if (value === null || value === undefined) return null;
128
+ const num = Number(value);
129
+ return Number.isFinite(num) ? num : null;
130
+ }
@@ -0,0 +1,19 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ export type StepImageUploadHandler = (image: Blob) => Promise<{ url: string }>;
4
+
5
+ let globalUploadHandler: StepImageUploadHandler | null = null;
6
+
7
+ export function setGlobalStepImageUploadHandler(handler: StepImageUploadHandler | null) {
8
+ globalUploadHandler = handler;
9
+ }
10
+
11
+ export function useStepImageUpload(): StepImageUploadHandler | null {
12
+ const [handler, setHandler] = useState<StepImageUploadHandler | null>(globalUploadHandler);
13
+
14
+ useEffect(() => {
15
+ setHandler(globalUploadHandler);
16
+ }, []);
17
+
18
+ return handler;
19
+ }
@@ -140,6 +140,7 @@
140
140
  display: flex;
141
141
  flex-direction: column;
142
142
  gap: 0.35rem;
143
+ position: relative;
143
144
  }
144
145
 
145
146
  .bn-step-field__top {
@@ -150,6 +151,9 @@
150
151
  }
151
152
 
152
153
  .bn-step-field__label {
154
+ display: inline-flex;
155
+ align-items: center;
156
+ gap: 0.35rem;
153
157
  font-size: 0.8rem;
154
158
  text-transform: uppercase;
155
159
  letter-spacing: 0.08em;
@@ -216,6 +220,54 @@
216
220
  pointer-events: none;
217
221
  }
218
222
 
223
+ .bn-step-suggestions {
224
+ position: absolute;
225
+ left: 0;
226
+ right: 0;
227
+ top: calc(100% + 0.25rem);
228
+ background: rgba(255, 255, 255, 0.98);
229
+ border-radius: 0.65rem;
230
+ border: 1px solid rgba(37, 99, 235, 0.25);
231
+ box-shadow: 0 18px 35px rgba(15, 23, 42, 0.16);
232
+ padding: 0.25rem;
233
+ display: flex;
234
+ flex-direction: column;
235
+ gap: 0.15rem;
236
+ z-index: 5;
237
+ max-height: 12rem;
238
+ overflow-y: auto;
239
+ }
240
+
241
+ .bn-step-suggestion {
242
+ border: none;
243
+ background: transparent;
244
+ border-radius: 0.5rem;
245
+ padding: 0.45rem 0.75rem;
246
+ text-align: left;
247
+ display: flex;
248
+ align-items: center;
249
+ justify-content: space-between;
250
+ gap: 0.5rem;
251
+ cursor: pointer;
252
+ color: #0f172a;
253
+ }
254
+
255
+ .bn-step-suggestion:hover,
256
+ .bn-step-suggestion--active {
257
+ background: rgba(59, 130, 246, 0.1);
258
+ }
259
+
260
+ .bn-step-suggestion__title {
261
+ font-weight: 600;
262
+ font-size: 0.95rem;
263
+ }
264
+
265
+ .bn-step-suggestion__meta {
266
+ font-size: 0.75rem;
267
+ font-weight: 600;
268
+ color: rgba(15, 23, 42, 0.65);
269
+ }
270
+
219
271
  .bn-inline-image {
220
272
  display: block;
221
273
  max-width: 100%;
package/src/index.ts CHANGED
@@ -12,4 +12,19 @@ export {
12
12
  type CustomPartialBlock,
13
13
  } from "./editor/customMarkdownConverter";
14
14
 
15
+ export {
16
+ useStepAutocomplete,
17
+ parseStepsFromJsonApi,
18
+ setGlobalStepSuggestionsFetcher,
19
+ type StepSuggestion,
20
+ type StepJsonApiDocument,
21
+ type StepJsonApiResource,
22
+ } from "./editor/stepAutocomplete";
23
+
24
+ export {
25
+ useStepImageUpload,
26
+ setGlobalStepImageUploadHandler,
27
+ type StepImageUploadHandler,
28
+ } from "./editor/stepImageUpload";
29
+
15
30
  export const testomatioEditorClassName = "markdown testomatio-editor";