waypoint.ts
1import { InputRule, mergeAttributes, Node } from "@tiptap/core";
2import "./rtw-waypoint";
3
4function safeParse<T, U>(s: string | null | undefined, or: T): T | U {
5 try {
6 return JSON.parse(s)
7 } catch {
8 return or
9 }
10}
11
12// contracts:ignore TypeEquivalence - point uses [number, number] tuple for [lon, lat] coordinates
13
14// Constraint type matching Go Constraint struct
15// Constraints are arrays of {type: string, value: string} objects
16export interface Constraint {
17 type: string;
18 value: string;
19}
20
21// Route type matching Go Route struct
22export interface Route {
23 id?: string;
24 d?: number; // Distance in kilometers
25 t?: number; // Time in minutes
26 bbox?: [number, number, number, number]; // [min_lon, min_lat, max_lon, max_lat]
27}
28
29export interface WaypointAttributes {
30 // contracts:ignore TypeEquivalence - number matches Go float32 and Rescript float
31 id: string;
32 // contracts:ignore TypeEquivalence - [number, number] tuple matches Go []float32 and Rescript (float, float)
33 point: [number, number];
34 // contracts:ignore NullableType - Record<string, Route> | null matches Go map[string]Route
35 from: Record<string, Route> | null;
36 label: string;
37 gid: string;
38 country: string;
39 // Constraints are array of constraint strings (e.g., "adv(80)")
40 cons: string[] | null;
41 nonroutable: boolean;
42 // Error codes from routing failures (e.g., ["RouteTooFar"])
43 error: string[] | null;
44}
45
46export const Waypoint = Node.create(() => {
47 return {
48 name: "waypoint",
49 group: "inline",
50 inline: true,
51 atom: true,
52 selectable: true,
53 draggable: false,
54
55 addAttributes() {
56 this.editor?.view.root;
57 return {
58 type: {
59 default: "waypoint",
60 parseHTML: (element) => element.getAttribute("type"),
61 },
62 id: {
63 default: null,
64 parseHTML: (element) => element.getAttribute("id"),
65 },
66 point: {
67 default: null,
68 parseHTML: (element) =>
69 safeParse(element.getAttribute("point"), null),
70 renderHTML: (attributes) =>
71 attributes.point
72 ? { point: JSON.stringify(attributes.point) }
73 : {},
74 },
75 from: {
76 default: null,
77 parseHTML: (element) =>
78 safeParse(element.getAttribute("from"), null),
79 renderHTML: (attributes) =>
80 attributes.from
81 ? { from: JSON.stringify(attributes.from) }
82 : {},
83 },
84 label: {
85 default: "",
86 parseHTML: (element) => element.getAttribute("label"),
87 },
88 gid: {
89 default: "",
90 parseHTML: (element) => element.getAttribute("gid"),
91 },
92 country: {
93 default: "",
94 parseHTML: (element) =>
95 element.getAttribute("country"),
96 },
97 cons: {
98 default: null,
99 parseHTML: (element) =>
100 safeParse(element.getAttribute("cons"), null),
101 renderHTML: (attributes) =>
102 attributes.cons
103 ? { cons: JSON.stringify(attributes.cons) }
104 : {},
105 },
106 nonroutable: {
107 default: false,
108 parseHTML: (element) => {
109 const val = element.getAttribute("nonroutable");
110 return val === "true" || val === true;
111 },
112 renderHTML: (attributes) =>
113 attributes.nonroutable
114 ? { nonroutable: "true" }
115 : {},
116 },
117 error: {
118 default: null,
119 parseHTML: (element) =>
120 safeParse(element.getAttribute("error"), null),
121 renderHTML: (attributes) =>
122 attributes.error
123 ? { error: JSON.stringify(attributes.error) }
124 : {},
125 },
126 };
127 },
128 parseHTML() {
129 return [
130 {
131 tag: "rtw-waypoint",
132 attrs: { "type": "waypoint" },
133 },
134 ];
135 },
136 renderHTML({ HTMLAttributes }) {
137 return [
138 "rtw-waypoint",
139 mergeAttributes(HTMLAttributes, {
140 "type": "waypoint",
141 }),
142 "",
143 ];
144 },
145 addInputRules() {
146 return [
147 // routable waypoint, auto routes from previous
148 new InputRule({
149 find: /@/,
150 handler: ({ state, range }) => {
151 // also exposes match, commands, chain, can
152 console.debug("got waypoint search");
153 const evt = new WaypointSearchEvent(
154 { from: range.from, to: range.from + 1 },
155 routableWaypointContent,
156 );
157 const id = state.tr.getMeta("id");
158 const element = id
159 ? document.querySelector(`div.tiptap.id-${id}`)
160 : document.querySelector("div.tiptap");
161 if (!element) return null;
162 console.debug("dispatching from", element);
163 element.dispatchEvent(evt);
164 },
165 undoable: true,
166 }),
167 ];
168 },
169 };
170});
171
172function routableWaypointContent({
173 point,
174 label,
175 id,
176 gid,
177 country,
178 cons,
179 error,
180}: Partial<WaypointAttributes>) {
181 return [
182 {
183 type: "waypoint",
184 attrs: {
185 id,
186 point,
187 label,
188 country,
189 gid,
190 from: null,
191 cons: cons || null,
192 nonroutable: false,
193 error: error || null,
194 },
195 },
196 { type: "text", text: " " },
197 ];
198};
199
200export class WaypointSearchEvent extends Event {
201 range: {from: number; to: number;} | null;
202 content: (p: Partial<WaypointAttributes>) => ReturnType<typeof routableWaypointContent>;
203
204 constructor(
205 range,
206 content,
207 ) {
208 super("waypointsearch", {
209 bubbles: true,
210 cancelable: true,
211 composed: true,
212 });
213 this.range = range;
214 this.content = content;
215 }
216}
217