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