mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-10-29 18:28:28 -04:00
151 lines
4.7 KiB
JavaScript
151 lines
4.7 KiB
JavaScript
import {mergeAttributes, Node} from '@tiptap/core';
|
|
import {PluginKey} from '@tiptap/pm/state';
|
|
import Suggestion from '@tiptap/suggestion';
|
|
|
|
import {getJson} from '../api';
|
|
|
|
// How each kind of link is written in a post's markdown. Crops and members are
|
|
// found by name, plantings by slug, as the server expands them (see
|
|
// Haml::Filters::GrowstuffMarkdown).
|
|
const WRITTEN = {
|
|
crop: () => 'crop',
|
|
member: () => 'member',
|
|
planting: ({slug}) => `planting:${slug}`,
|
|
};
|
|
|
|
const PATTERN = /^\[([^[\]]+)\]\((crop|member|planting:([\w-]+))\)/;
|
|
|
|
const clean = (label) => String(label).replace(/[[\]]/g, '');
|
|
|
|
// A link to a crop, a member or one of your plantings, shown in the text as a
|
|
// chip, and written as [name](crop), [name](member) or [name](planting:slug).
|
|
// It is added by typing @ and choosing from the list (see `suggestion`).
|
|
export function createLinkChip({suggestion}) {
|
|
return Node.create({
|
|
name: 'linkChip',
|
|
group: 'inline',
|
|
inline: true,
|
|
atom: true,
|
|
selectable: true,
|
|
|
|
addAttributes() {
|
|
return {
|
|
kind: {default: 'crop', parseHTML: (el) => el.getAttribute('data-kind')},
|
|
label: {default: '', parseHTML: (el) => el.getAttribute('data-label')},
|
|
slug: {default: null, parseHTML: (el) => el.getAttribute('data-slug')},
|
|
};
|
|
},
|
|
|
|
parseHTML() {
|
|
return [{tag: 'span[data-link-chip]'}];
|
|
},
|
|
|
|
renderHTML({node, HTMLAttributes}) {
|
|
return [
|
|
'span',
|
|
mergeAttributes(HTMLAttributes, {
|
|
'data-link-chip': '',
|
|
'data-kind': node.attrs.kind,
|
|
'data-label': node.attrs.label,
|
|
'data-slug': node.attrs.slug,
|
|
'class': `link-chip link-chip-${node.attrs.kind}`,
|
|
}),
|
|
`@${node.attrs.label}`,
|
|
];
|
|
},
|
|
|
|
renderText({node}) {
|
|
return `@${node.attrs.label}`;
|
|
},
|
|
|
|
markdownTokenizer: {
|
|
name: 'linkChip',
|
|
level: 'inline',
|
|
start: (src) => src.search(/\[[^[\]]+\]\((?:crop|member|planting:[\w-]+)\)/),
|
|
tokenize(src) {
|
|
const match = PATTERN.exec(src);
|
|
if (!match) return undefined;
|
|
const kind = match[2].split(':')[0];
|
|
return {type: 'linkChip', raw: match[0], kind, label: match[1], slug: match[3] || null};
|
|
},
|
|
},
|
|
|
|
parseMarkdown(token) {
|
|
return {type: 'linkChip', attrs: {kind: token.kind, label: token.label, slug: token.slug}};
|
|
},
|
|
|
|
renderMarkdown(node) {
|
|
const {kind, label, slug} = node.attrs;
|
|
return `[${clean(label)}](${WRITTEN[kind]({slug})})`;
|
|
},
|
|
|
|
addProseMirrorPlugins() {
|
|
return [
|
|
Suggestion({
|
|
editor: this.editor,
|
|
pluginKey: new PluginKey('linkChipSuggestion'),
|
|
char: '@',
|
|
allowSpaces: false,
|
|
command: ({editor, range, props}) => {
|
|
editor
|
|
.chain()
|
|
.focus()
|
|
.insertContentAt(range, [
|
|
{type: 'linkChip', attrs: {kind: props.kind, label: clean(props.label), slug: props.slug || null}},
|
|
{type: 'text', text: ' '},
|
|
])
|
|
.run();
|
|
},
|
|
...suggestion,
|
|
}),
|
|
];
|
|
},
|
|
});
|
|
}
|
|
|
|
const PER_GROUP = 5;
|
|
const SEARCH_DELAY = 200; // ms to wait for typing to pause before searching
|
|
|
|
// What @ can link to matching `term`: crops, members, and your own plantings.
|
|
// Each is {group, kind, label, slug?, detail?}.
|
|
export async function fetchLinkItems(term, {signal} = {}) {
|
|
const query = encodeURIComponent(term);
|
|
const [crops, others] = await Promise.all([
|
|
getJson(`/crops/search.json?term=${query}`, {signal}),
|
|
getJson(`/posts/link_suggestions.json?term=${query}`, {signal}),
|
|
]);
|
|
|
|
const items = [];
|
|
if (crops.ok && Array.isArray(crops.data)) {
|
|
crops.data.slice(0, PER_GROUP).forEach((crop) => {
|
|
items.push({group: 'Crops', kind: 'crop', label: crop.name, detail: crop.matched_alternate_name});
|
|
});
|
|
}
|
|
if (others.ok && others.data) {
|
|
(others.data.members || []).forEach((member) => {
|
|
items.push({group: 'Members', kind: 'member', label: member.name});
|
|
});
|
|
(others.data.plantings || []).forEach((planting) => {
|
|
items.push({group: 'Yours', kind: 'planting', label: planting.name, slug: planting.slug, detail: 'Planting'});
|
|
});
|
|
}
|
|
return items;
|
|
}
|
|
|
|
// The `items` for the suggestion: searches once typing pauses. A search that a
|
|
// newer one has overtaken answers with the last results, so they never go back.
|
|
export function createLinkSearch() {
|
|
let latest = 0;
|
|
let last = [];
|
|
return async function search(term) {
|
|
if (!term.trim()) return [];
|
|
const id = ++latest;
|
|
await new Promise((resolve) => setTimeout(resolve, SEARCH_DELAY));
|
|
if (id !== latest) return last;
|
|
const items = await fetchLinkItems(term);
|
|
if (id !== latest) return last;
|
|
last = items;
|
|
return items;
|
|
};
|
|
}
|