Skip to content

Commit 6ef690d

Browse files
committed
feat: move useInlineWpEvents hook into the lib
1 parent 22eaf67 commit 6ef690d

File tree

2 files changed

+194
-1
lines changed

2 files changed

+194
-1
lines changed

lib/hooks/useInlineWpEvents.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
import { useEffect } from 'react';
2+
import type { BlockNoteEditor, InlineContentFromConfig } from '@blocknote/core';
3+
import { wpBridge, makeInstanceId } from '../../lib';
4+
import type { InlineWpSize } from '../../lib';
5+
6+
//Uses `any` generics this hook is schema-agnostic by design.
7+
type AnyEditor = BlockNoteEditor<any, any, any>;
8+
type AnyInlineNode = InlineContentFromConfig<any, any>;
9+
10+
interface InlineWpNode {
11+
type:'inlineWorkPackage';
12+
props:{
13+
wpid:string;
14+
// instanceId MUST be globally unique per document
15+
instanceId:string;
16+
size:InlineWpSize;
17+
};
18+
content:unknown[];
19+
}
20+
21+
// Must match InlineWpSize union update both if sizes change
22+
const VALID_SIZES:Set<InlineWpSize> = new Set(['xxs', 'xs', 's', 'm']);
23+
24+
function isInlineWpNode(node:unknown): node is InlineWpNode {
25+
if (typeof node !== 'object' || node === null) return false;
26+
const n = node as Record<string, unknown>;
27+
if (n['type'] !== 'inlineWorkPackage') return false;
28+
29+
const props = n['props'];
30+
if (typeof props !== 'object' || props === null) return false;
31+
32+
const p = props as Record<string, unknown>;
33+
return (
34+
typeof p['instanceId'] === 'string' &&
35+
typeof p['wpid'] === 'string' &&
36+
typeof p['size'] === 'string' && VALID_SIZES.has(p['size'] as InlineWpSize)
37+
);
38+
}
39+
40+
function asInlineNode(node:InlineWpNode):AnyInlineNode {
41+
return node as unknown as AnyInlineNode;
42+
}
43+
44+
interface FoundInlineBlock {
45+
blockId:string;
46+
content:AnyInlineNode[];
47+
chip:InlineWpNode;
48+
}
49+
50+
function findInlineChip(editor:AnyEditor, instanceId:string):FoundInlineBlock | null {
51+
let found: FoundInlineBlock | null = null;
52+
53+
editor.forEachBlock((block) => {
54+
if (found) return false;
55+
56+
const content = (block.content ?? []) as AnyInlineNode[];
57+
const chip = content.find(
58+
(node):node is AnyInlineNode & InlineWpNode =>
59+
isInlineWpNode(node) && node.props.instanceId === instanceId
60+
);
61+
62+
if (chip) {
63+
found = { blockId:block.id, content, chip };
64+
return false;
65+
}
66+
67+
return true;
68+
});
69+
70+
return found;
71+
}
72+
73+
// The updater returns the updated node, or null to remove it.
74+
// Returns found so the caller can use it without a second traversal.
75+
function updateInlineChip(
76+
editor:AnyEditor,
77+
instanceId:string,
78+
updater:(chip:InlineWpNode) => InlineWpNode | null
79+
): FoundInlineBlock | null {
80+
const found = findInlineChip(editor, instanceId);
81+
if (!found) return null;
82+
83+
const updatedContent = found.content.reduce<AnyInlineNode[]>((acc, node) => {
84+
if (!isInlineWpNode(node) || node.props.instanceId !== instanceId) {
85+
acc.push(node);
86+
return acc;
87+
}
88+
const updated = updater(node);
89+
if (updated !== null) acc.push(asInlineNode(updated));
90+
return acc;
91+
}, []);
92+
93+
editor.updateBlock(found.blockId, { content:updatedContent } as any);
94+
return found;
95+
}
96+
97+
function moveCursorAfter(editor:AnyEditor, blockId:string):void {
98+
requestAnimationFrame(() => {
99+
editor.focus();
100+
editor.setTextCursorPosition(blockId, 'end');
101+
102+
const cursor = editor.getTextCursorPosition();
103+
if (!cursor?.nextBlock && cursor?.block) {
104+
editor.insertBlocks([{ type: 'paragraph', content:[] }], cursor.block.id, 'after');
105+
}
106+
107+
const updated = editor.getTextCursorPosition();
108+
if (updated?.nextBlock) {
109+
editor.setTextCursorPosition(updated.nextBlock.id, 'start');
110+
}
111+
});
112+
}
113+
114+
function handleResize(editor:AnyEditor, instanceId:string, size:InlineWpSize):void {
115+
if (size === 'm') {
116+
handlePromoteToBlock(editor, instanceId);
117+
return;
118+
}
119+
updateInlineChip(editor, instanceId, (chip) => ({
120+
...chip,
121+
props:{ ...chip.props, size },
122+
}));
123+
}
124+
125+
function handleDelete(editor:AnyEditor, instanceId:string):void {
126+
updateInlineChip(editor, instanceId, () => null);
127+
}
128+
129+
function handlePromoteToBlock(editor:AnyEditor, instanceId:string):void {
130+
const found = findInlineChip(editor, instanceId);
131+
if (!found) return;
132+
133+
// wpid must be a positive integer
134+
const wpid = Number(found.chip.props.wpid);
135+
if (Number.isNaN(wpid) || wpid <= 0) return;
136+
137+
updateInlineChip(editor, instanceId, () => null);
138+
139+
const [insertedBlock] = editor.insertBlocks(
140+
[{ type:'openProjectWorkPackage', props: { wpid, initialized:true } } as any],
141+
found.blockId,
142+
'after'
143+
);
144+
145+
if (insertedBlock?.id) {
146+
moveCursorAfter(editor, insertedBlock.id);
147+
}
148+
}
149+
150+
function handleConvertToInline(editor: AnyEditor, wpid: number, size: InlineWpSize, blockId: string): void {
151+
const block = editor.getBlock(blockId);
152+
if (!block) return;
153+
154+
const instanceId = makeInstanceId();
155+
156+
const [insertedParagraph] = editor.insertBlocks(
157+
[
158+
{
159+
type: 'paragraph',
160+
content: [
161+
{ type: 'inlineWorkPackage', props: { wpid: String(wpid), instanceId, size } },
162+
],
163+
} as any,
164+
],
165+
blockId,
166+
'before'
167+
);
168+
169+
editor.removeBlocks([blockId]);
170+
171+
requestAnimationFrame(() => {
172+
if (!insertedParagraph?.id) return;
173+
editor.focus();
174+
editor.setTextCursorPosition(insertedParagraph.id, 'end');
175+
});
176+
}
177+
178+
// editor instance is stable for the lifetime of the component re-subscription only on editor replacement
179+
export function useInlineWpEvents(editor: AnyEditor):void {
180+
useEffect(() => {
181+
const offResize = wpBridge.onResize(({ instanceId, size }) => handleResize(editor, instanceId, size));
182+
const offDelete = wpBridge.onDelete(({ instanceId }) => handleDelete(editor, instanceId));
183+
const offToInline = wpBridge.onConvertToInline(({ wpid, size, blockId }) =>
184+
handleConvertToInline(editor, wpid, size, blockId)
185+
);
186+
return () => {
187+
offResize();
188+
offDelete();
189+
offToInline();
190+
};
191+
}, [editor]);
192+
}

lib/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,5 @@ export { initializeOpBlockNoteExtensions } from "./initialize";
44
export { wpBridge } from "./services/wpBridge.ts";
55
export type { WpResizePayload, WpDeletePayload, WpToInlinePayload } from "./services/wpBridge.ts";
66
export { makeInstanceId } from "./services/utils.ts";
7-
export type { InlineWpSize } from "./components/InlineWorkPackage/types";
7+
export type { InlineWpSize } from "./components/InlineWorkPackage/types";
8+
export { useInlineWpEvents } from './hooks/useInlineWpEvents';

0 commit comments

Comments
 (0)