Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/deploy-branches.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
#- name: Debugging with ssh
# uses: lhotari/action-upterm@v1
- name: Checkout nool repo on current branch # STEP 1
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
path: source
- name: Add name of current branch to environment as BRANCH_NAME
Expand Down Expand Up @@ -43,7 +43,7 @@ jobs:
pnpm run build
working-directory: ./source
- name: Checkout website build artifacts repo # STEP 4
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
repository: disconcision/disconcision.github.io
token: ${{ secrets.DEPLOY_NOOL }}
Expand Down
123 changes: 114 additions & 9 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,44 @@
import { Component } from "solid-js";
import { Component, createSignal } from "solid-js";
import { createStore, SetStoreFunction } from "solid-js/store";
import { go } from "./Update";
import * as Model from "./Model";
import * as Action from "./Action";
import * as Stage from "./Stage";
import * as Keyboard from "./Keyboard";
import { SettingsView } from "./view/SettingsView";
import { Seed } from "./view/SeedView";
import * as ExpToPat from "./syntax/ExpToPat";
import * as Animate from "./Animate";
import { useHazelIntegration } from "./hazel/useHazelIntegration";
import {
serializeForHazel,
deserializeFromHazel,
} from "./hazel/hazel-serialization";
//import { Toolbar } from "./view/ToolsView";

export type SetModel = SetStoreFunction<Model.t>;

const App: Component = () => {
// Get URL parameters for Hazel integration
const urlParams = new URLSearchParams(window.location.search);
const hazelId = urlParams.get("id");
const [isHazelEmbed] = createSignal(!!hazelId);

const [model, setModel] = createStore({ ...Model.init });
const [constraints, setConstraints] = createSignal<{
maxWidth: number;
maxHeight: number;
} | null>(null);
const [hazelReady, setHazelReady] = createSignal(!isHazelEmbed());
let activeTransition: ViewTransition | null = null;
let pendingHoverAction: Action.t | null = null;

Animate.init();

const inject = (a: Action.t) => {
// CRITICAL: setHover actions must be deferred during view transitions.
// Transform actions trigger hover changes when tool sides flip (mouse position changes),
// causing DOM updates that interfere with ongoing view transitions and break animations.
// causing DOM updates that int`erfere with ongoing view transitions and break animations.
// Solution: defer hover actions until transition completes, then apply the final hover state.
if (a.t === "setHover") {
if (activeTransition) {
Expand Down Expand Up @@ -65,24 +82,112 @@ const App: Component = () => {
}
});
};
document.addEventListener("keydown", Keyboard.keydown(inject), false);
document.addEventListener("keyup", Keyboard.keyup(inject), false);

// Setup Hazel integration if running as exolivelit
let hazelIntegration: ReturnType<typeof useHazelIntegration> | null = null;
let enhancedInject = inject;

if (isHazelEmbed()) {
hazelIntegration = useHazelIntegration({
id: hazelId,
codec: "json",
onInit: (valueStr: string) => {
console.log("Received init from Hazel:", valueStr);
const exp = deserializeFromHazel(valueStr);
// Use proper stage update to recalculate statics and projectors
const newStage = Stage.put_exp(model.stage, exp);
setModel("stage", newStage);
setHazelReady(true); // Show UI now that we have hazel data

// Force reflow/repaint after a short delay to fix rendering issues
setTimeout(() => {
document.body.offsetHeight; // Force reflow
if (hazelIntegration) {
const rect = document
.getElementById("main")
?.getBoundingClientRect();
if (rect) {
hazelIntegration.resize(rect.width, rect.height);
}
}
}, 100);
},
onConstraints: (c) => {
console.log("Received constraints from Hazel:", c);
setConstraints(c);
},
});

// Send updates to Hazel when expression changes
const sendUpdate = () => {
if (hazelIntegration) {
const serialized = serializeForHazel(model.stage.exp);
hazelIntegration.setSyntax(serialized);
}
};

// Enhanced inject that also sends updates to Hazel
enhancedInject = (a: Action.t) => {
inject(a);
// Send update after actions that modify the expression
if (a.t !== "setHover" && a.t !== "unsetSelections") {
setTimeout(sendUpdate, 0); // Delay to ensure model update completes
}
};
}
document.addEventListener("keydown", Keyboard.keydown(enhancedInject), false);
document.addEventListener("keyup", Keyboard.keyup(enhancedInject), false);
// document.addEventListener("transitionstart", (e) => {
// in_transition = true;
// });
// document.addEventListener("transitionend", (e) => {
// in_transition = false;
// });
// Apply Hazel constraints with responsive scaling
const mainStyle = () => {
const c = constraints();
if (!c) return {};

// Nool's natural dimensions (tight around content with padding)
const naturalWidth = 640;
const naturalHeight = 480;

// Calculate scale factor - never scale up, only down
const scaleX = c.maxWidth / naturalWidth;
const scaleY = c.maxHeight / naturalHeight;
const scale = Math.min(1, scaleX, scaleY);

if (scale < 1) {
// Scale down when constrained
return {
transform: `scale(${scale})`,
"transform-origin": "top left",
width: `${naturalWidth}px`,
height: `${naturalHeight}px`,
};
} else {
// Use natural size with max constraints
return {
"max-width": `${c.maxWidth}px`,
"max-height": `${c.maxHeight}px`,
};
}
};

return (
<div
id="main"
class={model.settings.theme}
classList={{ selected: model.stage.selection === "unselected" }}
classList={{
selected: model.stage.selection === "unselected",
"hazel-embed": isHazelEmbed(),
}}
style={mainStyle()}
>
{/* <div class="logo" /> */}
{/* Toolbar({ model, inject }) */}
{Seed({ model, inject })}
{SettingsView({ model, inject })}
<div style={{ opacity: hazelReady() ? 1 : 0 }}>
{Seed({ model, inject: enhancedInject })}
{!isHazelEmbed() && SettingsView({ model, inject: enhancedInject })}
</div>
</div>
);
};
Expand Down
161 changes: 161 additions & 0 deletions src/hazel/hazel-integration-base.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { createEffect, createSignal, onCleanup } from "solid-js";

/**
* Hazel integration types and utilities adapted for SolidJS
*/

// Message protocol types
export type ToHazelMessage =
| { type: "ready"; id: string }
| { type: "setSyntax"; id: string; codec: string; value: string }
| { type: "resize"; id: string; width: number; height: number };

export type FromHazelMessage =
| { type: "init"; id: string; value: string }
| {
type: "constraints";
id: string;
maxWidth: number;
maxHeight: number;
minWidth?: number;
minHeight?: number;
};

export function isFromHazelMessage(data: unknown): data is FromHazelMessage {
return (
data !== null &&
typeof data === "object" &&
"type" in data &&
"id" in data &&
["init", "constraints"].includes(
(data as Record<string, unknown>).type as string
) &&
typeof (data as Record<string, unknown>).id === "string"
);
}

// Utility: Simple trailing throttle
function throttle<T extends (...args: any[]) => void>(fn: T, ms: number): T {
let timeoutId: number | null = null;
let lastArgs: any[] | null = null;

const run = () => {
timeoutId = null;
if (lastArgs) {
fn(...lastArgs);
lastArgs = null;
}
};

return ((...args: any[]) => {
lastArgs = args;
if (timeoutId === null) {
timeoutId = window.setTimeout(run, ms);
}
}) as T;
}

// Resize strategy interface
export interface ResizeStrategy {
setup(params: {
id: string;
sendToHazel: (message: ToHazelMessage) => void;
}): () => void; // Returns cleanup function
}

// Configuration for the base hook
export interface HazelIntegrationConfig {
id: string;
codec: string;
onInit?: (value: string) => void;
onConstraints?: (constraints: {
maxWidth: number;
maxHeight: number;
minWidth?: number;
minHeight?: number;
}) => void;
resizeStrategy?: ResizeStrategy;
}

/**
* Core Hazel integration for SolidJS - handles protocol, messaging, and setup
*/
export function createHazelIntegration(config: HazelIntegrationConfig) {
const { id, codec, onInit, onConstraints, resizeStrategy } = config;
const [hasInit, setHasInit] = createSignal(false);

// Get target origin from URL params or fallback
const targetOrigin =
new URLSearchParams(window.location.search).get("parentOrigin") || "*";

// Core message sender
const sendToHazel = (message: ToHazelMessage) => {
if (window.parent && window.parent !== window) {
window.parent.postMessage(message, targetOrigin);
}
};

// Throttled setSyntax function
const setSyntax = throttle((value: string) => {
sendToHazel({ type: "setSyntax", id, codec, value });
}, 50);

// Manual resize function
const resize = (width: number, height: number) => {
sendToHazel({ type: "resize", id, width, height });
};

// Message listener and ready handshake
createEffect(() => {
const handleMessage = (event: MessageEvent) => {
const data = event.data;

if (!isFromHazelMessage(data) || data.id !== id) {
return;
}

switch (data.type) {
case "init":
if (onInit) {
onInit(data.value);
}
break;
case "constraints":
if (onConstraints) {
onConstraints({
maxWidth: data.maxWidth,
maxHeight: data.maxHeight,
minWidth: data.minWidth,
minHeight: data.minHeight,
});
}
break;
}
};

window.addEventListener("message", handleMessage);

// Send ready message when component mounts
if (!hasInit()) {
sendToHazel({ type: "ready", id });
setHasInit(true);
}

onCleanup(() => {
window.removeEventListener("message", handleMessage);
});
});

// Setup resize strategy if provided
createEffect(() => {
if (!resizeStrategy) return;

const cleanup = resizeStrategy.setup({ id, sendToHazel });
onCleanup(cleanup);
});

return {
setSyntax,
resize,
};
}
Loading