This commit is contained in:
Orion Reed 2025-07-28 19:26:05 -04:00
parent 5a58ed4ebc
commit 3ccb69c94c
5 changed files with 4528 additions and 5058 deletions

View File

@ -1 +0,0 @@
nodeLinker: node-modules

4003
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,270 +1,538 @@
import { import {
BaseBoxShapeUtil, BaseBoxShapeUtil,
Editor, Editor,
Geometry2d, Geometry2d,
HTMLContainer, HTMLContainer,
Rectangle2d, Rectangle2d,
TLBaseShape, TLBaseShape,
TLOnResizeHandler, TLOnResizeHandler,
TLShape, TLShape,
TLShapeId, TLShapeId,
resizeBox, resizeBox,
} from 'tldraw' } from "tldraw";
import { getUserId } from './storeUtils' import { getUserId } from "./storeUtils";
import { getEdge } from './propagators/tlgraph' import { getEdge } from "./propagators/tlgraph";
export type ValueType = "SCALAR" | "BOOLEAN" | "STRING" | "RANK" | "NONE" export type ValueType = "SCALAR" | "BOOLEAN" | "STRING" | "RANK" | "NONE";
export type ISocialShape = TLBaseShape< export type ISocialShape = TLBaseShape<
"social", "social",
{ {
w: number w: number;
h: number h: number;
text: string text: string;
selector: string selector: string;
valueType: ValueType valueType: ValueType;
values: Record<string, any> values: Record<string, any>;
value: any value: any;
syntaxError: boolean syntaxError: boolean;
} }
> >;
export class SocialShapeUtil extends BaseBoxShapeUtil<ISocialShape> { export class SocialShapeUtil extends BaseBoxShapeUtil<ISocialShape> {
static override type = 'social' as const static override type = "social" as const;
override canBind = () => true private valueTypeRegex = (valueType: ValueType) =>
override canEdit = () => false new RegExp(`${valueType}\\s*\\((.*?)\\)|${valueType}`);
override getDefaultProps(): ISocialShape['props'] { override canBind = () => true;
return { w: 160 * 2, h: 90 * 2, text: '', selector: '', valueType: "NONE", values: {}, value: null, syntaxError: false } override canEdit = () => false;
} override getDefaultProps(): ISocialShape["props"] {
override onResize: TLOnResizeHandler<ISocialShape> = (shape, info) => { return {
return resizeBox(shape, info) w: 160 * 2,
} h: 90 * 2,
override getGeometry(shape: ISocialShape): Geometry2d { text: "",
return new Rectangle2d({ selector: "",
width: shape.props.w, valueType: "NONE",
height: shape.props.h, values: {},
isFilled: true, value: null,
}) syntaxError: false,
} };
}
override onResize: TLOnResizeHandler<ISocialShape> = (shape, info) => {
return resizeBox(shape, info);
};
override getGeometry(shape: ISocialShape): Geometry2d {
return new Rectangle2d({
width: shape.props.w,
height: shape.props.h,
isFilled: true,
});
}
indicator(shape: ISocialShape) { indicator(shape: ISocialShape) {
return ( return <rect width={shape.props.w} height={shape.props.h} rx={4} />;
<rect }
width={shape.props.w}
height={shape.props.h}
rx={4}
/>
)
}
override component(shape: ISocialShape) { override component(shape: ISocialShape) {
const currentUser = getUserId(this.editor) const currentUser = getUserId(this.editor);
const defaultValues = { const defaultValues = {
BOOLEAN: false, BOOLEAN: false,
SCALAR: 0, SCALAR: 0,
DEFAULT: null DEFAULT: null,
} };
const handleOnChange = (newValue: boolean | number) => { const handleOnChange = (newValue: boolean | number) => {
this.updateProps(shape, { values: { ...shape.props.values, [currentUser]: newValue } }) console.log("NEW VALUE", newValue);
this.updateValue(shape.id) this.updateProps(shape, {
} values: { ...shape.props.values, [currentUser]: newValue },
});
this.updateValue(shape.id);
};
const handleTextChange = (text: string) => { const handleTextChange = (text: string) => {
let valueType: ValueType = "NONE" let valueType: ValueType = "NONE";
const selector = text.match(/@([a-zA-Z]+)/)?.[1] || '' const selector = text.match(/@([a-zA-Z]+)/)?.[1] || "";
if (text.includes('SCALAR')) { if (text.includes("SCALAR")) {
valueType = 'SCALAR' valueType = "SCALAR";
} else if (text.includes('BOOLEAN')) { } else if (text.includes("BOOLEAN")) {
valueType = 'BOOLEAN' valueType = "BOOLEAN";
} else if (text.includes('STRING')) { } else if (text.includes("STRING")) {
valueType = 'STRING' valueType = "STRING";
} else if (text.includes('RANK')) { } else if (text.includes("RANK")) {
valueType = 'RANK' valueType = "RANK";
} }
if (valueType !== shape.props.valueType) { if (valueType !== shape.props.valueType) {
this.updateProps(shape, { text, valueType, selector, values: {} }) this.updateProps(shape, { text, valueType, selector, values: {} });
} else { } else {
this.updateProps(shape, { text, selector }) this.updateProps(shape, { text, selector });
} }
this.updateValue(shape.id) this.updateValue(shape.id);
} };
return ( const args = this.getArgs(shape, shape.props.valueType);
<HTMLContainer style={{ padding: 4, borderRadius: 4, border: '1px solid #ccc', outline: shape.props.syntaxError ? '2px solid orange' : 'none' }} onPointerDown={(e) => e.stopPropagation()}> const inputMap = getInputMap(this.editor, shape);
<textarea style={{ width: '100%', height: '60%', border: '1px solid lightgrey', resize: 'none', pointerEvents: 'all' }} value={shape.props.text} onChange={(e) => handleTextChange(e.target.value)} /> const usedInputs: any[] = [];
<ValueInterface for (const arg of args) {
type={shape.props.valueType ?? null} if (arg !== false && arg !== true && inputMap[arg]) {
value={shape.props.values[currentUser] ?? defaultValues[shape.props.valueType as keyof typeof defaultValues]} if (Array.isArray(inputMap[arg].value)) {
values={shape.props.values} usedInputs.push(...inputMap[arg].value);
onChange={handleOnChange} /> } else {
</HTMLContainer> usedInputs.push(inputMap[arg].value);
) }
} }
}
// console.log("USED INPUTS", usedInputs)
private updateValue(shapeId: TLShapeId) { return (
const shape = this.editor.getShape(shapeId) as ISocialShape <HTMLContainer
const valueType = shape.props.valueType style={{
const vals = Array.from(Object.values(shape.props.values)) padding: 4,
borderRadius: 4,
border: "1px solid #ccc",
outline: shape.props.syntaxError ? "2px solid orange" : "none",
}}
onPointerDown={(e) => e.stopPropagation()}
>
<textarea
style={{
width: "100%",
minHeight: "4em",
height: "auto",
border: "1px solid lightgrey",
resize: "none",
pointerEvents: "all",
}}
value={shape.props.text}
onChange={(e) => handleTextChange(e.target.value)}
rows={2}
/>
<ValueInterface
type={shape.props.valueType ?? null}
value={
shape.props.values[currentUser] ??
defaultValues[shape.props.valueType as keyof typeof defaultValues]
}
values={shape.props.values}
inputs={usedInputs}
onChange={handleOnChange}
editor={this.editor}
/>
</HTMLContainer>
);
}
const functionBody = `return ${shape.props.text.replace(valueType, 'VALUES')};` private getArgs(shape: ISocialShape, valueType: ValueType) {
const match = shape.props.text.match(this.valueTypeRegex(valueType));
let args: (string | number | boolean)[] = [];
if (match?.[1]) {
args = match[1].split(",").map((arg) => {
const trimmed = arg.trim();
if (trimmed === "true") return true;
if (trimmed === "false") return false;
if (!Number.isNaN(Number(trimmed))) return Number(trimmed);
return trimmed;
});
}
return args;
}
const sum = (vals: number[] | boolean[]) => { private updateValue(shapeId: TLShapeId) {
if (valueType === 'SCALAR') { const shape = this.editor.getShape(shapeId) as ISocialShape;
return (vals as number[]).reduce((acc, val) => acc + val, 0) const valueType = shape.props.valueType;
} const vals = Array.from(Object.values(shape.props.values));
if (valueType === 'BOOLEAN') {
//@ts-ignore
return vals.filter(Boolean).length;
}
}
const average = (vals: number[] | boolean[]) => {
if (valueType === 'SCALAR') {
return (vals as number[]).reduce((acc, val) => acc + val, 0) / vals.length
}
if (valueType === 'BOOLEAN') {
//@ts-ignore
return vals.filter(Boolean).length;
}
}
const inputMap = getInputMap(this.editor, shape) const functionBody = `return ${shape.props.text.replace(
this.valueTypeRegex(valueType),
"VALUES"
)};`;
try { const sum = (vals: number[] | boolean[]) => {
const paramNames = ['sum', 'average', 'VALUES', ...Object.keys(inputMap)] if (valueType === "SCALAR") {
const paramValues = [sum, average, vals, ...Object.values(inputMap).map(s => s.value)] return (vals as number[]).reduce((acc, val) => acc + val, 0);
const func = new Function(...paramNames, functionBody) }
const result = func(...paramValues) if (valueType === "BOOLEAN") {
//@ts-ignore
return vals.filter(Boolean).length;
}
};
const average = (vals: number[] | boolean[]) => {
if (valueType === "SCALAR") {
return (
(vals as number[]).reduce((acc, val) => acc + val, 0) / vals.length
);
}
if (valueType === "BOOLEAN") {
//@ts-ignore
return vals.filter(Boolean).length;
}
};
if (typeof result === 'function') { const countVotes = (votes: Array<{ up: string[]; down: string[] }>) => {
this.updateProps({ ...shape, props: { ...shape.props, value: null } }, { syntaxError: true }) const voteCount = votes.reduce((acc, vote) => {
return for (const item of vote.up) {
} acc[item] = (acc[item] || 0) + 1;
console.log("VALUE", result) }
this.updateProps(shape, { value: result, syntaxError: false }) for (const item of vote.down) {
} catch (e) { acc[item] = (acc[item] || 0) - 1;
console.log("ERROR", e) }
this.updateProps(shape, { syntaxError: true }) return acc;
} }, {} as Record<string, number>);
}
return Object.entries(voteCount).sort((a, b) => b[1] - a[1]);
};
private updateProps(shape: ISocialShape, props: Partial<ISocialShape['props']>) { const inputMap = getInputMap(this.editor, shape);
this.editor.updateShape<ISocialShape>({
id: shape.id, try {
type: 'social', const paramNames = [
props: { "sum",
...shape.props, "average",
...props "countVotes",
}, "VALUES",
}) ...Object.keys(inputMap),
} ];
const paramValues = [
sum,
average,
countVotes,
vals,
...Object.values(inputMap).map((s) => s.value),
];
const func = new Function(...paramNames, functionBody);
const result = func(...paramValues);
if (typeof result === "function") {
this.updateProps(
{ ...shape, props: { ...shape.props, value: null } },
{ syntaxError: true }
);
return;
}
this.updateProps(shape, { value: result, syntaxError: false });
} catch (e) {
console.log("ERROR", e);
this.updateProps(shape, { syntaxError: true });
}
}
private updateProps(
shape: ISocialShape,
props: Partial<ISocialShape["props"]>
) {
this.editor.updateShape<ISocialShape>({
id: shape.id,
type: "social",
props: {
...shape.props,
...props,
},
});
}
} }
function ValueInterface({ type, value, values, onChange }: { type: ValueType; value: boolean | number | string; values: Record<string, any>; onChange: (value: any) => void }) { function ValueInterface({
switch (type) { type,
case 'BOOLEAN': value,
return <> values,
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '4px' }}> onChange,
<input style={{ pointerEvents: 'all', width: '20px', height: '20px', margin: 0 }} type="checkbox" checked={value as boolean} onChange={(e) => onChange(e.target.checked)} /> inputs,
<div style={{ width: '1px', height: '20px', backgroundColor: 'grey' }} /> editor,
{Object.values(values).map((bool, i) => ( }: {
<div key={`boolean-${i}`} style={{ backgroundColor: bool ? 'blue' : 'white', width: '20px', height: '20px', border: '1px solid lightgrey', borderRadius: 2 }} /> type: ValueType;
))} value: boolean | number | string;
</div> values: Record<string, any>;
</> onChange: (value: any) => void;
case 'STRING': inputs: any[];
return ( editor: Editor;
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', gap: '4px' }}> }) {
<textarea switch (type) {
style={{ case "BOOLEAN":
pointerEvents: 'all', return (
width: '100%', <>
minHeight: '60px', <div
resize: 'vertical', style={{
padding: '4px', display: "flex",
boxSizing: 'border-box', flexDirection: "row",
}} alignItems: "center",
value={value as string} gap: "4px",
onChange={(e) => onChange(e.target.value)} }}
/> >
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <input
<div style={{ display: 'flex', gap: '2px' }}> style={{
{Object.values(values).filter(value => value !== '').map((_, i) => ( pointerEvents: "all",
<div width: "20px",
key={`string-${i}`} height: "20px",
style={{ margin: 0,
width: '8px', }}
height: '8px', type="checkbox"
backgroundColor: 'blue', checked={value as boolean}
borderRadius: '50%', onChange={(e) => onChange(e.target.checked)}
}} />
/> <div
))} style={{ width: "1px", height: "20px", backgroundColor: "grey" }}
</div> />
</div> {Object.values(values).map((bool, i) => (
</div> <div
); key={`boolean-${i}`}
case 'SCALAR': style={{
return ( backgroundColor: bool ? "blue" : "white",
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '4px' }}> width: "20px",
<input height: "20px",
type="range" border: "1px solid lightgrey",
min="0" borderRadius: 2,
max="1" }}
step="0.01" />
value={value as number ?? 0} ))}
onChange={(e) => onChange(parseFloat(e.target.value))} </div>
style={{ width: '100px', pointerEvents: 'all' }} </>
/> );
<span style={{ fontFamily: 'monospace' }}>{(value as number ?? 0).toFixed(2)}</span> case "STRING":
<div style={{ width: '1px', height: '20px', backgroundColor: 'grey' }} /> return (
{Object.values(values).map((val, i) => ( <div
<div style={{
key={`scalar-${i}`} display: "flex",
style={{ flexDirection: "column",
backgroundColor: `rgba(0, 0, 255, ${val ?? 0})`, width: "100%",
width: '20px', gap: "4px",
height: '20px', }}
border: '1px solid lightgrey', >
borderRadius: 2 <textarea
}} style={{
/> pointerEvents: "all",
))} width: "100%",
</div> minHeight: "60px",
); resize: "vertical",
default: padding: "4px",
return <div style={{ marginTop: 10, textAlign: 'center' }}>No Interface...</div> boxSizing: "border-box",
} }}
value={value as string}
onChange={(e) => onChange(e.target.value)}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div style={{ display: "flex", gap: "2px" }}>
{Object.values(values)
.filter((value) => value !== "")
.map((_, i) => (
<div
key={`string-${i}`}
style={{
width: "8px",
height: "8px",
backgroundColor: "blue",
borderRadius: "50%",
}}
/>
))}
</div>
</div>
</div>
);
case "RANK": {
const currentUser = getUserId(editor);
return (
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
gap: "8px",
}}
>
{inputs.map((input, index) => (
<div
key={`rank-${index}`}
style={{
display: "flex",
alignItems: "center",
gap: "8px",
border: "1px solid lightgrey",
borderRadius: 4,
padding: "4px 8px",
}}
>
<span
style={{
flex: 1,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{input}
</span>
<button
type="button"
onClick={() => {
const newUp = [
...new Set([...(values[currentUser]?.up || []), input]),
];
//@ts-ignore
const newDown = (values[currentUser]?.down || []).filter(
(v: string) => v !== input
);
onChange({ up: newUp, down: newDown });
}}
style={{
cursor: "pointer",
padding: "4px 8px",
pointerEvents: "all",
backgroundColor: values[currentUser]?.up?.includes(input)
? "#4CAF50"
: "inherit",
}}
>
</button>
<button
type="button"
onClick={() => {
//@ts-ignore
const newUp = (values[currentUser]?.up || []).filter(
(v: string) => v !== input
);
//@ts-ignore
const newDown = [
...new Set([...(values[currentUser]?.down || []), input]),
];
onChange({ up: newUp, down: newDown });
}}
style={{
cursor: "pointer",
padding: "4px 8px",
pointerEvents: "all",
backgroundColor: values[currentUser]?.down?.includes(input)
? "#FF3B30"
: "inherit",
}}
>
</button>
</div>
))}
<div style={{ display: "flex", gap: "2px" }}>
{Object.values(values)
.filter((value) => value !== "")
.map((_, i) => (
<div
key={`rank-dot-${i}`}
style={{
width: "8px",
height: "8px",
backgroundColor: "blue",
borderRadius: "50%",
}}
/>
))}
</div>
</div>
);
}
case "SCALAR":
return (
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: "4px",
}}
>
<input
type="range"
min="0"
max="1"
step="0.01"
value={(value as number) ?? 0}
onChange={(e) => onChange(parseFloat(e.target.value))}
style={{ width: "100px", pointerEvents: "all" }}
/>
<span style={{ fontFamily: "monospace" }}>
{((value as number) ?? 0).toFixed(2)}
</span>
<div
style={{ width: "1px", height: "20px", backgroundColor: "grey" }}
/>
{Object.values(values).map((val, i) => (
<div
key={`scalar-${i}`}
style={{
backgroundColor: `rgba(0, 0, 255, ${val ?? 0})`,
width: "20px",
height: "20px",
border: "1px solid lightgrey",
borderRadius: 2,
}}
/>
))}
</div>
);
default:
return (
<div style={{ marginTop: 10, textAlign: "center" }}>
No Interface...
</div>
);
}
} }
function getInputMap(editor: Editor, shape: TLShape) { function getInputMap(editor: Editor, shape: TLShape) {
const arrowBindings = editor.getBindingsInvolvingShape( const arrowBindings = editor.getBindingsInvolvingShape(shape.id, "arrow");
shape.id, const arrows = arrowBindings.map((binding) =>
"arrow", editor.getShape(binding.fromId)
) );
const arrows = arrowBindings
.map((binding) => editor.getShape(binding.fromId))
return arrows.reduce((acc, arrow) => { return arrows.reduce((acc, arrow) => {
const edge = getEdge(arrow, editor); const edge = getEdge(arrow, editor);
if (edge && edge.to === shape.id) { if (edge && edge.to === shape.id) {
const sourceShape = editor.getShape(edge.from); const sourceShape = editor.getShape(edge.from);
if (sourceShape && edge.text) { if (sourceShape && edge.text) {
acc[edge.text] = { value: sourceShape.props.value || sourceShape.props.text || null, shapeId: sourceShape.id } //@ts-ignore
} acc[edge.text] = {
} //@ts-ignore
return acc; value: sourceShape.props.value || sourceShape.props.text || null,
}, {} as Record<string, { value: any, shapeId: TLShapeId }>); shapeId: sourceShape.id,
} };
}
function listenToShape(editor: Editor, shapeId: TLShapeId, callback: (prev: TLShape, next: TLShape) => void) { }
return editor.sideEffects.registerAfterChangeHandler<'shape'>('shape', (prev, next) => { return acc;
if (next.id === shapeId) { }, {} as Record<string, { value: any; shapeId: TLShapeId }>);
callback(prev, next)
}
})
} }

View File

@ -0,0 +1,16 @@
// vite.config.ts
import { defineConfig } from "file:///Users/orion/Repositories/Orion/ggraph/node_modules/vite/dist/node/index.js";
import react from "file:///Users/orion/Repositories/Orion/ggraph/node_modules/@vitejs/plugin-react/dist/index.mjs";
import wasm from "file:///Users/orion/Repositories/Orion/ggraph/node_modules/vite-plugin-wasm/exports/import.mjs";
import topLevelAwait from "file:///Users/orion/Repositories/Orion/ggraph/node_modules/vite-plugin-top-level-await/exports/import.mjs";
var vite_config_default = defineConfig({
plugins: [
react(),
wasm(),
topLevelAwait()
]
});
export {
vite_config_default as default
};
//# sourceMappingURL=data:application/json;base64,ewogICJ2ZXJzaW9uIjogMywKICAic291cmNlcyI6IFsidml0ZS5jb25maWcudHMiXSwKICAic291cmNlc0NvbnRlbnQiOiBbImNvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9kaXJuYW1lID0gXCIvVXNlcnMvb3Jpb24vUmVwb3NpdG9yaWVzL09yaW9uL2dncmFwaFwiO2NvbnN0IF9fdml0ZV9pbmplY3RlZF9vcmlnaW5hbF9maWxlbmFtZSA9IFwiL1VzZXJzL29yaW9uL1JlcG9zaXRvcmllcy9Pcmlvbi9nZ3JhcGgvdml0ZS5jb25maWcudHNcIjtjb25zdCBfX3ZpdGVfaW5qZWN0ZWRfb3JpZ2luYWxfaW1wb3J0X21ldGFfdXJsID0gXCJmaWxlOi8vL1VzZXJzL29yaW9uL1JlcG9zaXRvcmllcy9Pcmlvbi9nZ3JhcGgvdml0ZS5jb25maWcudHNcIjtpbXBvcnQgeyBkZWZpbmVDb25maWcgfSBmcm9tICd2aXRlJ1xuaW1wb3J0IHJlYWN0IGZyb20gJ0B2aXRlanMvcGx1Z2luLXJlYWN0J1xuaW1wb3J0IHdhc20gZnJvbSBcInZpdGUtcGx1Z2luLXdhc21cIjtcbmltcG9ydCB0b3BMZXZlbEF3YWl0IGZyb20gXCJ2aXRlLXBsdWdpbi10b3AtbGV2ZWwtYXdhaXRcIjtcblxuZXhwb3J0IGRlZmF1bHQgZGVmaW5lQ29uZmlnKHtcbiAgcGx1Z2luczogW1xuICAgIHJlYWN0KCksXG4gICAgd2FzbSgpLFxuICAgIHRvcExldmVsQXdhaXQoKVxuICBdLFxufSlcbiJdLAogICJtYXBwaW5ncyI6ICI7QUFBb1MsU0FBUyxvQkFBb0I7QUFDalUsT0FBTyxXQUFXO0FBQ2xCLE9BQU8sVUFBVTtBQUNqQixPQUFPLG1CQUFtQjtBQUUxQixJQUFPLHNCQUFRLGFBQWE7QUFBQSxFQUMxQixTQUFTO0FBQUEsSUFDUCxNQUFNO0FBQUEsSUFDTixLQUFLO0FBQUEsSUFDTCxjQUFjO0FBQUEsRUFDaEI7QUFDRixDQUFDOyIsCiAgIm5hbWVzIjogW10KfQo=

4816
yarn.lock

File diff suppressed because it is too large Load Diff