proper draw cleanup
This commit is contained in:
parent
aa76419eda
commit
8f31fbe275
5 changed files with 157 additions and 154 deletions
2
packages/react/dist/index.cjs.js
vendored
2
packages/react/dist/index.cjs.js
vendored
File diff suppressed because one or more lines are too long
297
packages/react/dist/index.es.js
vendored
297
packages/react/dist/index.es.js
vendored
|
|
@ -1,15 +1,15 @@
|
||||||
import n, { useCallback as C, useRef as P, useEffect as z, useState as E, useMemo as q, useLayoutEffect as I } from "react";
|
import n, { useCallback as N, useRef as x, useEffect as R, useState as y, useMemo as q, useLayoutEffect as I } from "react";
|
||||||
import G from "@uiw/react-codemirror";
|
import J from "@uiw/react-codemirror";
|
||||||
import { Decoration as b, EditorView as K } from "@codemirror/view";
|
import { Decoration as E, EditorView as K } from "@codemirror/view";
|
||||||
import { StateEffect as O, StateField as j } from "@codemirror/state";
|
import { StateEffect as O, StateField as j } from "@codemirror/state";
|
||||||
import { javascript as J } from "@codemirror/lang-javascript";
|
import { javascript as Q } from "@codemirror/lang-javascript";
|
||||||
import { tags as r } from "@lezer/highlight";
|
import { tags as r } from "@lezer/highlight";
|
||||||
import { createTheme as Q } from "@uiw/codemirror-themes";
|
import { createTheme as W } from "@uiw/codemirror-themes";
|
||||||
import { useInView as W } from "react-hook-inview";
|
import { useInView as X } from "react-hook-inview";
|
||||||
import { webaudioOutput as X, getAudioContext as Y } from "@strudel.cycles/webaudio";
|
import { webaudioOutput as Y, getAudioContext as Z } from "@strudel.cycles/webaudio";
|
||||||
import { repl as Z } from "@strudel.cycles/core";
|
import { repl as ee } from "@strudel.cycles/core";
|
||||||
import { transpiler as ee } from "@strudel.cycles/transpiler";
|
import { transpiler as te } from "@strudel.cycles/transpiler";
|
||||||
const te = Q({
|
const re = W({
|
||||||
theme: "dark",
|
theme: "dark",
|
||||||
settings: {
|
settings: {
|
||||||
background: "#222",
|
background: "#222",
|
||||||
|
|
@ -42,113 +42,113 @@ const te = Q({
|
||||||
{ tag: r.invalid, color: "#ffffff" }
|
{ tag: r.invalid, color: "#ffffff" }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
const L = O.define(), re = j.define({
|
const L = O.define(), oe = j.define({
|
||||||
create() {
|
create() {
|
||||||
return b.none;
|
return E.none;
|
||||||
},
|
},
|
||||||
update(e, t) {
|
update(e, t) {
|
||||||
try {
|
try {
|
||||||
for (let o of t.effects)
|
for (let o of t.effects)
|
||||||
if (o.is(L))
|
if (o.is(L))
|
||||||
if (o.value) {
|
if (o.value) {
|
||||||
const s = b.mark({ attributes: { style: "background-color: #FFCA2880" } });
|
const a = E.mark({ attributes: { style: "background-color: #FFCA2880" } });
|
||||||
e = b.set([s.range(0, t.newDoc.length)]);
|
e = E.set([a.range(0, t.newDoc.length)]);
|
||||||
} else
|
} else
|
||||||
e = b.set([]);
|
e = E.set([]);
|
||||||
return e;
|
return e;
|
||||||
} catch (o) {
|
} catch (o) {
|
||||||
return console.warn("flash error", o), e;
|
return console.warn("flash error", o), e;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
provide: (e) => K.decorations.from(e)
|
provide: (e) => K.decorations.from(e)
|
||||||
}), oe = (e) => {
|
}), ne = (e) => {
|
||||||
e.dispatch({ effects: L.of(!0) }), setTimeout(() => {
|
e.dispatch({ effects: L.of(!0) }), setTimeout(() => {
|
||||||
e.dispatch({ effects: L.of(!1) });
|
e.dispatch({ effects: L.of(!1) });
|
||||||
}, 200);
|
}, 200);
|
||||||
}, R = O.define(), ne = j.define({
|
}, A = O.define(), ae = j.define({
|
||||||
create() {
|
create() {
|
||||||
return b.none;
|
return E.none;
|
||||||
},
|
},
|
||||||
update(e, t) {
|
update(e, t) {
|
||||||
try {
|
try {
|
||||||
for (let o of t.effects)
|
for (let o of t.effects)
|
||||||
if (o.is(R)) {
|
if (o.is(A)) {
|
||||||
const s = o.value.map(
|
const a = o.value.map(
|
||||||
(c) => (c.context.locations || []).map(({ start: f, end: d }) => {
|
(s) => (s.context.locations || []).map(({ start: u, end: l }) => {
|
||||||
const u = c.context.color || "#FFCA28";
|
const d = s.context.color || "#FFCA28";
|
||||||
let a = t.newDoc.line(f.line).from + f.column, i = t.newDoc.line(d.line).from + d.column;
|
let c = t.newDoc.line(u.line).from + u.column, i = t.newDoc.line(l.line).from + l.column;
|
||||||
const m = t.newDoc.length;
|
const f = t.newDoc.length;
|
||||||
return a > m || i > m ? void 0 : b.mark({ attributes: { style: `outline: 1.5px solid ${u};` } }).range(a, i);
|
return c > f || i > f ? void 0 : E.mark({ attributes: { style: `outline: 1.5px solid ${d};` } }).range(c, i);
|
||||||
})
|
})
|
||||||
).flat().filter(Boolean) || [];
|
).flat().filter(Boolean) || [];
|
||||||
e = b.set(s, !0);
|
e = E.set(a, !0);
|
||||||
}
|
}
|
||||||
return e;
|
return e;
|
||||||
} catch {
|
} catch {
|
||||||
return b.set([]);
|
return E.set([]);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
provide: (e) => K.decorations.from(e)
|
provide: (e) => K.decorations.from(e)
|
||||||
}), ae = [J(), te, ne, re];
|
}), se = [Q(), re, ae, oe];
|
||||||
function se({ value: e, onChange: t, onViewChanged: o, onSelectionChange: s, options: c, editorDidMount: f }) {
|
function ce({ value: e, onChange: t, onViewChanged: o, onSelectionChange: a, options: s, editorDidMount: u }) {
|
||||||
const d = C(
|
const l = N(
|
||||||
(i) => {
|
(i) => {
|
||||||
t?.(i);
|
t?.(i);
|
||||||
},
|
},
|
||||||
[t]
|
[t]
|
||||||
), u = C(
|
), d = N(
|
||||||
(i) => {
|
(i) => {
|
||||||
o?.(i);
|
o?.(i);
|
||||||
},
|
},
|
||||||
[o]
|
[o]
|
||||||
), a = C(
|
), c = N(
|
||||||
(i) => {
|
(i) => {
|
||||||
i.selectionSet && s && s?.(i.state.selection);
|
i.selectionSet && a && a?.(i.state.selection);
|
||||||
},
|
},
|
||||||
[s]
|
[a]
|
||||||
);
|
);
|
||||||
return /* @__PURE__ */ n.createElement(n.Fragment, null, /* @__PURE__ */ n.createElement(G, {
|
return /* @__PURE__ */ n.createElement(n.Fragment, null, /* @__PURE__ */ n.createElement(J, {
|
||||||
value: e,
|
value: e,
|
||||||
onChange: d,
|
onChange: l,
|
||||||
onCreateEditor: u,
|
onCreateEditor: d,
|
||||||
onUpdate: a,
|
onUpdate: c,
|
||||||
extensions: ae
|
extensions: se
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
function S(...e) {
|
function S(...e) {
|
||||||
return e.filter(Boolean).join(" ");
|
return e.filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
function ce({ view: e, pattern: t, active: o, getTime: s }) {
|
function ie({ view: e, pattern: t, active: o, getTime: a }) {
|
||||||
const c = P([]), f = P();
|
const s = x([]), u = x();
|
||||||
z(() => {
|
R(() => {
|
||||||
if (e)
|
if (e)
|
||||||
if (t && o) {
|
if (t && o) {
|
||||||
let u = function() {
|
let d = function() {
|
||||||
try {
|
try {
|
||||||
const a = s(), m = [Math.max(f.current || a, a - 1 / 10, 0), a + 1 / 60];
|
const c = a(), f = [Math.max(u.current || c, c - 1 / 10, 0), c + 1 / 60];
|
||||||
f.current = m[1], c.current = c.current.filter((l) => l.whole.end > a);
|
u.current = f[1], s.current = s.current.filter((m) => m.whole.end > c);
|
||||||
const p = t.queryArc(...m).filter((l) => l.hasOnset());
|
const v = t.queryArc(...f).filter((m) => m.hasOnset());
|
||||||
c.current = c.current.concat(p), e.dispatch({ effects: R.of(c.current) });
|
s.current = s.current.concat(v), e.dispatch({ effects: A.of(s.current) });
|
||||||
} catch {
|
} catch {
|
||||||
e.dispatch({ effects: R.of([]) });
|
e.dispatch({ effects: A.of([]) });
|
||||||
}
|
}
|
||||||
d = requestAnimationFrame(u);
|
l = requestAnimationFrame(d);
|
||||||
}, d = requestAnimationFrame(u);
|
}, l = requestAnimationFrame(d);
|
||||||
return () => {
|
return () => {
|
||||||
cancelAnimationFrame(d);
|
cancelAnimationFrame(l);
|
||||||
};
|
};
|
||||||
} else
|
} else
|
||||||
c.current = [], e.dispatch({ effects: R.of([]) });
|
s.current = [], e.dispatch({ effects: A.of([]) });
|
||||||
}, [t, o, e]);
|
}, [t, o, e]);
|
||||||
}
|
}
|
||||||
const ie = "_container_3i85k_1", le = "_header_3i85k_5", de = "_buttons_3i85k_9", ue = "_button_3i85k_9", fe = "_buttonDisabled_3i85k_17", me = "_error_3i85k_21", ge = "_body_3i85k_25", v = {
|
const le = "_container_3i85k_1", de = "_header_3i85k_5", ue = "_buttons_3i85k_9", fe = "_button_3i85k_9", me = "_buttonDisabled_3i85k_17", ge = "_error_3i85k_21", pe = "_body_3i85k_25", b = {
|
||||||
container: ie,
|
container: le,
|
||||||
header: le,
|
header: de,
|
||||||
buttons: de,
|
buttons: ue,
|
||||||
button: ue,
|
button: fe,
|
||||||
buttonDisabled: fe,
|
buttonDisabled: me,
|
||||||
error: me,
|
error: ge,
|
||||||
body: ge
|
body: pe
|
||||||
};
|
};
|
||||||
function B({ type: e }) {
|
function B({ type: e }) {
|
||||||
return /* @__PURE__ */ n.createElement("svg", {
|
return /* @__PURE__ */ n.createElement("svg", {
|
||||||
|
|
@ -174,133 +174,134 @@ function B({ type: e }) {
|
||||||
})
|
})
|
||||||
}[e]);
|
}[e]);
|
||||||
}
|
}
|
||||||
function pe({
|
function he({
|
||||||
defaultOutput: e,
|
defaultOutput: e,
|
||||||
interval: t,
|
interval: t,
|
||||||
getTime: o,
|
getTime: o,
|
||||||
evalOnMount: s = !1,
|
evalOnMount: a = !1,
|
||||||
initialCode: c = "",
|
initialCode: s = "",
|
||||||
autolink: f = !1,
|
autolink: u = !1,
|
||||||
|
beforeEval: l,
|
||||||
afterEval: d,
|
afterEval: d,
|
||||||
onEvalError: u
|
onEvalError: c
|
||||||
}) {
|
}) {
|
||||||
const [a, i] = E(), [m, p] = E(), [l, N] = E(c), [D, F] = E(l), [k, H] = E(), [M, _] = E(!1), A = l !== D, { scheduler: w, evaluate: y, start: h, stop: U, pause: $ } = q(
|
const [i, f] = y(), [v, m] = y(), [h, D] = y(s), [_, C] = y(h), [P, z] = y(), [k, F] = y(!1), H = h !== _, { scheduler: w, evaluate: g, start: U, stop: $, pause: G } = q(
|
||||||
() => Z({
|
() => ee({
|
||||||
interval: t,
|
interval: t,
|
||||||
defaultOutput: e,
|
defaultOutput: e,
|
||||||
onSchedulerError: i,
|
onSchedulerError: f,
|
||||||
onEvalError: (g) => {
|
onEvalError: (p) => {
|
||||||
p(g), u?.(g);
|
m(p), c?.(p);
|
||||||
},
|
},
|
||||||
getTime: o,
|
getTime: o,
|
||||||
transpiler: ee,
|
transpiler: te,
|
||||||
beforeEval: ({ code: g }) => {
|
beforeEval: ({ code: p }) => {
|
||||||
N(g);
|
D(p), l?.();
|
||||||
},
|
},
|
||||||
afterEval: ({ pattern: g, code: V }) => {
|
afterEval: ({ pattern: p, code: V }) => {
|
||||||
F(V), H(g), p(), i(), f && (window.location.hash = "#" + encodeURIComponent(btoa(V))), d?.();
|
C(V), z(p), m(), f(), u && (window.location.hash = "#" + encodeURIComponent(btoa(V))), d?.();
|
||||||
},
|
},
|
||||||
onToggle: (g) => _(g)
|
onToggle: (p) => F(p)
|
||||||
}),
|
}),
|
||||||
[e, t, o]
|
[e, t, o]
|
||||||
), x = C(async (g = !0) => y(l, g), [y, l]), T = P();
|
), M = N(async (p = !0) => g(h, p), [g, h]), T = x();
|
||||||
return z(() => {
|
return R(() => {
|
||||||
!T.current && s && l && (T.current = !0, x());
|
!T.current && a && h && (T.current = !0, M());
|
||||||
}, [x, s, l]), z(() => () => {
|
}, [M, a, h]), R(() => () => {
|
||||||
w.stop();
|
w.stop();
|
||||||
}, [w]), {
|
}, [w]), {
|
||||||
code: l,
|
code: h,
|
||||||
setCode: N,
|
setCode: D,
|
||||||
error: a || m,
|
error: i || v,
|
||||||
schedulerError: a,
|
schedulerError: i,
|
||||||
scheduler: w,
|
scheduler: w,
|
||||||
evalError: m,
|
evalError: v,
|
||||||
evaluate: y,
|
evaluate: g,
|
||||||
activateCode: x,
|
activateCode: M,
|
||||||
activeCode: D,
|
activeCode: _,
|
||||||
isDirty: A,
|
isDirty: H,
|
||||||
pattern: k,
|
pattern: P,
|
||||||
started: M,
|
started: k,
|
||||||
start: h,
|
start: U,
|
||||||
stop: U,
|
stop: $,
|
||||||
pause: $,
|
pause: G,
|
||||||
togglePlay: async () => {
|
togglePlay: async () => {
|
||||||
M ? w.pause() : await x();
|
k ? w.pause() : await M();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const he = () => Y().currentTime;
|
const ve = () => Z().currentTime;
|
||||||
function xe({ tune: e, hideOutsideView: t = !1, init: o, enableKeyboard: s }) {
|
function Re({ tune: e, hideOutsideView: t = !1, init: o, enableKeyboard: a }) {
|
||||||
const {
|
const {
|
||||||
code: c,
|
code: s,
|
||||||
setCode: f,
|
setCode: u,
|
||||||
evaluate: d,
|
evaluate: l,
|
||||||
activateCode: u,
|
activateCode: d,
|
||||||
error: a,
|
error: c,
|
||||||
isDirty: i,
|
isDirty: i,
|
||||||
activeCode: m,
|
activeCode: f,
|
||||||
pattern: p,
|
pattern: v,
|
||||||
started: l,
|
started: m,
|
||||||
scheduler: N,
|
scheduler: h,
|
||||||
togglePlay: D,
|
togglePlay: D,
|
||||||
stop: F
|
stop: _
|
||||||
} = pe({
|
} = he({
|
||||||
initialCode: e,
|
initialCode: e,
|
||||||
defaultOutput: X,
|
defaultOutput: Y,
|
||||||
getTime: he
|
getTime: ve
|
||||||
}), [k, H] = E(), [M, _] = W({
|
}), [C, P] = y(), [z, k] = X({
|
||||||
threshold: 0.01
|
threshold: 0.01
|
||||||
}), A = P(), w = q(() => ((_ || !t) && (A.current = !0), _ || A.current), [_, t]);
|
}), F = x(), H = q(() => ((k || !t) && (F.current = !0), k || F.current), [k, t]);
|
||||||
return ce({
|
return ie({
|
||||||
view: k,
|
view: C,
|
||||||
pattern: p,
|
pattern: v,
|
||||||
active: l && !m?.includes("strudel disable-highlighting"),
|
active: m && !f?.includes("strudel disable-highlighting"),
|
||||||
getTime: () => N.getPhase()
|
getTime: () => h.getPhase()
|
||||||
}), I(() => {
|
}), I(() => {
|
||||||
if (s) {
|
if (a) {
|
||||||
const y = async (h) => {
|
const w = async (g) => {
|
||||||
(h.ctrlKey || h.altKey) && (h.code === "Enter" ? (h.preventDefault(), oe(k), await u()) : h.code === "Period" && (F(), h.preventDefault()));
|
(g.ctrlKey || g.altKey) && (g.code === "Enter" ? (g.preventDefault(), ne(C), await d()) : g.code === "Period" && (_(), g.preventDefault()));
|
||||||
};
|
};
|
||||||
return window.addEventListener("keydown", y, !0), () => window.removeEventListener("keydown", y, !0);
|
return window.addEventListener("keydown", w, !0), () => window.removeEventListener("keydown", w, !0);
|
||||||
}
|
}
|
||||||
}, [s, p, c, d, F, k]), /* @__PURE__ */ n.createElement("div", {
|
}, [a, v, s, l, _, C]), /* @__PURE__ */ n.createElement("div", {
|
||||||
className: v.container,
|
className: b.container,
|
||||||
ref: M
|
ref: z
|
||||||
}, /* @__PURE__ */ n.createElement("div", {
|
}, /* @__PURE__ */ n.createElement("div", {
|
||||||
className: v.header
|
className: b.header
|
||||||
}, /* @__PURE__ */ n.createElement("div", {
|
}, /* @__PURE__ */ n.createElement("div", {
|
||||||
className: v.buttons
|
className: b.buttons
|
||||||
}, /* @__PURE__ */ n.createElement("button", {
|
}, /* @__PURE__ */ n.createElement("button", {
|
||||||
className: S(v.button, l ? "sc-animate-pulse" : ""),
|
className: S(b.button, m ? "sc-animate-pulse" : ""),
|
||||||
onClick: () => D()
|
onClick: () => D()
|
||||||
}, /* @__PURE__ */ n.createElement(B, {
|
}, /* @__PURE__ */ n.createElement(B, {
|
||||||
type: l ? "pause" : "play"
|
type: m ? "pause" : "play"
|
||||||
})), /* @__PURE__ */ n.createElement("button", {
|
})), /* @__PURE__ */ n.createElement("button", {
|
||||||
className: S(i ? v.button : v.buttonDisabled),
|
className: S(i ? b.button : b.buttonDisabled),
|
||||||
onClick: () => u()
|
onClick: () => d()
|
||||||
}, /* @__PURE__ */ n.createElement(B, {
|
}, /* @__PURE__ */ n.createElement(B, {
|
||||||
type: "refresh"
|
type: "refresh"
|
||||||
}))), a && /* @__PURE__ */ n.createElement("div", {
|
}))), c && /* @__PURE__ */ n.createElement("div", {
|
||||||
className: v.error
|
className: b.error
|
||||||
}, a.message)), /* @__PURE__ */ n.createElement("div", {
|
}, c.message)), /* @__PURE__ */ n.createElement("div", {
|
||||||
className: v.body
|
className: b.body
|
||||||
}, w && /* @__PURE__ */ n.createElement(se, {
|
}, H && /* @__PURE__ */ n.createElement(ce, {
|
||||||
value: c,
|
value: s,
|
||||||
onChange: f,
|
onChange: u,
|
||||||
onViewChanged: H
|
onViewChanged: P
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
function Re(e) {
|
function Pe(e) {
|
||||||
return z(() => (window.addEventListener("message", e), () => window.removeEventListener("message", e)), [e]), C((t) => window.postMessage(t, "*"), []);
|
return R(() => (window.addEventListener("message", e), () => window.removeEventListener("message", e)), [e]), N((t) => window.postMessage(t, "*"), []);
|
||||||
}
|
}
|
||||||
const Pe = (e) => I(() => (window.addEventListener("keydown", e, !0), () => window.removeEventListener("keydown", e, !0)), [e]);
|
const ze = (e) => I(() => (window.addEventListener("keydown", e, !0), () => window.removeEventListener("keydown", e, !0)), [e]);
|
||||||
export {
|
export {
|
||||||
se as CodeMirror,
|
ce as CodeMirror,
|
||||||
xe as MiniRepl,
|
Re as MiniRepl,
|
||||||
S as cx,
|
S as cx,
|
||||||
oe as flash,
|
ne as flash,
|
||||||
ce as useHighlighting,
|
ie as useHighlighting,
|
||||||
Pe as useKeydown,
|
ze as useKeydown,
|
||||||
Re as usePostMessage,
|
Pe as usePostMessage,
|
||||||
pe as useStrudel
|
he as useStrudel
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ function useStrudel({
|
||||||
evalOnMount = false,
|
evalOnMount = false,
|
||||||
initialCode = '',
|
initialCode = '',
|
||||||
autolink = false,
|
autolink = false,
|
||||||
|
beforeEval,
|
||||||
afterEval,
|
afterEval,
|
||||||
onEvalError,
|
onEvalError,
|
||||||
}) {
|
}) {
|
||||||
|
|
@ -36,6 +37,7 @@ function useStrudel({
|
||||||
transpiler,
|
transpiler,
|
||||||
beforeEval: ({ code }) => {
|
beforeEval: ({ code }) => {
|
||||||
setCode(code);
|
setCode(code);
|
||||||
|
beforeEval?.();
|
||||||
},
|
},
|
||||||
afterEval: ({ pattern: _pattern, code }) => {
|
afterEval: ({ pattern: _pattern, code }) => {
|
||||||
setActiveCode(code);
|
setActiveCode(code);
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,10 @@ function App() {
|
||||||
defaultOutput: webaudioOutput,
|
defaultOutput: webaudioOutput,
|
||||||
getTime,
|
getTime,
|
||||||
autolink: true,
|
autolink: true,
|
||||||
|
beforeEval: () => {
|
||||||
|
cleanupUi();
|
||||||
|
cleanupDraw();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// init code
|
// init code
|
||||||
|
|
@ -175,7 +179,6 @@ function App() {
|
||||||
} else {
|
} else {
|
||||||
logger('[repl] stopped. tip: you can also stop by pressing ctrl+dot', 'highlight');
|
logger('[repl] stopped. tip: you can also stop by pressing ctrl+dot', 'highlight');
|
||||||
stop();
|
stop();
|
||||||
// cleanupDraw();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
|
|
@ -186,9 +189,6 @@ function App() {
|
||||||
const handleShuffle = async () => {
|
const handleShuffle = async () => {
|
||||||
const { code, name } = getRandomTune();
|
const { code, name } = getRandomTune();
|
||||||
logger(`[repl] ✨ loading random tune "${name}"`);
|
logger(`[repl] ✨ loading random tune "${name}"`);
|
||||||
|
|
||||||
cleanupDraw();
|
|
||||||
cleanupUi();
|
|
||||||
resetLoadedSamples();
|
resetLoadedSamples();
|
||||||
await prebake(); // declare default samples
|
await prebake(); // declare default samples
|
||||||
await evaluate(code, false);
|
await evaluate(code, false);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue