nitropage
Version:
A free and open source, extensible visual page builder based on SolidStart.
250 lines (216 loc) • 7.25 kB
text/typescript
import { BlueprintData, EditorUiState, Element, State } from "#../types";
import { EditorUiContext, SettingsContext } from "#lib/context/admin";
import { StateContext } from "#lib/context/page";
import type { Settings } from "#lib/server/settings";
import { titleCase } from "#utils/string/titleCase";
import { createId } from "@paralleldrive/cuid2";
import { cloneDeep } from "es-toolkit";
import {
Accessor,
createEffect,
createMemo,
onCleanup,
onMount,
useContext,
} from "solid-js";
import { produce } from "solid-js/store";
import { makeEventListener } from "../../../lib/primitives/eventListener";
import { useRouterPage } from "../../admin/data";
import { createSlot } from "./slot";
export const duplicateElement = function (
element: Element,
draft: State,
ids: Record<string, string>,
parentSlotId?: string,
siblings?: string[],
) {
const nextId = (ids[element.id] ??= createId());
const clonedElement = cloneDeep(element);
clonedElement.id = nextId;
clonedElement.historyId = undefined;
clonedElement.slots = {};
if (parentSlotId != null) {
clonedElement.parentSlotId = parentSlotId;
}
draft.elements[clonedElement.id] = clonedElement;
if (siblings) {
siblings.push(clonedElement.id);
} else {
const siblingElements = draft.slots[element.parentSlotId!].elements;
const origIndex = siblingElements.indexOf(element.id);
siblingElements.splice(origIndex + 1, 0, clonedElement.id);
}
for (const [slotKey, slotId] of Object.entries(element.slots)) {
const prevSlot = draft.slots[slotId];
const nextSlotId = (ids[slotId] ??= createId());
const nextSlot = createSlot(slotKey, clonedElement.id, nextSlotId);
draft.slots[nextSlot.id] = nextSlot;
clonedElement.slots[slotKey] = nextSlot.id;
for (const childElementId of prevSlot.elements) {
duplicateElement(
draft.elements[childElementId],
draft,
ids,
nextSlot.id,
nextSlot.elements,
);
}
}
};
export const useSelectedElement = function () {
const [editorUi] = useContext(EditorUiContext)!;
const [state] = useContext(StateContext)!;
return createMemo(function () {
if (!editorUi.selectedElementId) {
return;
}
return state.elements[editorUi.selectedElementId];
});
};
export const useSelectedElementPreset = function (
element = useSelectedElement(),
) {
const [state] = useContext(StateContext)!;
return createMemo(function () {
const presetId = element()?.presetId;
if (!presetId) {
return;
}
return state.presets[presetId];
});
};
export const blueprintDataTitle = function <T extends BlueprintData>(
data: T,
fallback: string,
) {
const titleFn = data.title;
return titleFn ? titleFn() : titleCase(fallback);
};
export const useDarkMode = function (editorUi: EditorUiState) {
createEffect(function () {
document.documentElement.style["colorScheme"] = editorUi.viewport.darkMode
? "dark"
: "light";
});
};
export const useSetting = function <T extends keyof Settings>(
key: T,
from: Accessor<"pages" | "projects">,
) {
const [settings, setSettings] = useContext(SettingsContext)!;
const getter = (): Settings[T]["value"] => settings[from()][key]?.value;
const setter = function (
fn: (draft: Settings[T]["value"]) => Settings[T]["value"],
) {
setSettings(
produce(function (draft) {
const from_ = from();
const result = fn(draft[from_][key]?.value);
draft[from_][key]!.value = result;
draft[from_][key]!.from = result != null ? from_ : "default";
}),
);
};
return [getter, setter] as [typeof getter, typeof setter];
};
export const isElementInViewport = (el: globalThis.Element) => {
const screenBorder = 150;
const elRect = el.getBoundingClientRect();
const elCord = {
top: elRect.y,
bottom: elRect.y + elRect.height,
// TODO: Also check the horizontal axis
// left: elRect.x,
// right: elRect.x + elRect.width,
};
const vCord = {
top: window.screenTop + screenBorder,
bottom: window.screenTop + window.innerHeight - screenBorder,
// left: window.screenLeft,
// right: window.screenLeft + window.innerWidth,
};
// The viewport is inside of the element (for large elements)
if (elCord.top <= vCord.top && elCord.bottom >= vCord.bottom) {
return true;
}
// The top of the element is in the viewport
if (elCord.top >= vCord.top && elCord.top <= vCord.bottom) {
return true;
}
// The bottom of the element is in the viewport
if (elCord.bottom >= vCord.top && elCord.bottom <= vCord.bottom) {
return true;
}
return false;
};
export const useScrollRestoration = function (
editorUi: EditorUiState,
focusElementById: (id: string) => void,
) {
// Store information about the document, before it changed to a new tool
// The old information is used to calculate a proper scroll position in %
// This is needed because the restoration effect below might run too late, after the document already has started to change
let restorationInfo = { scrollHeight: 0, scrollTop: 0 };
let lastTool = editorUi.viewport.tool;
onMount(function () {
let interval = setInterval(function () {
if (lastTool != editorUi.viewport.tool) return;
restorationInfo.scrollHeight = document.documentElement.scrollHeight;
restorationInfo.scrollTop = document.documentElement.scrollTop;
}, 1000 / 20);
onCleanup(() => clearInterval(interval));
});
let scrollRaf: number | undefined = undefined;
makeEventListener(
() => window,
"wheel",
(evt) => {
if (scrollRaf == null) return;
evt.preventDefault();
},
{ passive: false },
);
// Restore the scroll position when switching between different editor tools
createEffect(function (initial) {
lastTool = editorUi.viewport.tool;
const creatingElementId = editorUi.creatingElementId;
if (initial) {
return;
}
const document = window.document.documentElement;
const outerHeight = document.clientHeight;
const oldScroll = restorationInfo.scrollTop;
const oldHeight = restorationInfo.scrollHeight;
const precision = 100;
const scrollPercent =
Math.round((precision / (oldHeight - outerHeight)) * oldScroll) /
precision;
if (scrollRaf != undefined) {
cancelAnimationFrame(scrollRaf);
scrollRaf = undefined;
}
// The slot animations take 0.08s (80ms) -> 5 frames -> we add one extra to smooth it out
const restoreScroll = (frames = 6) => {
scrollRaf = requestAnimationFrame(() => {
if (creatingElementId) {
focusElementById(creatingElementId);
} else {
const nextHeight = document.scrollHeight;
const newScroll = (nextHeight - outerHeight) * scrollPercent;
document.scrollTo({ behavior: "instant", top: newScroll });
}
frames -= 1;
if (frames == 0) {
scrollRaf = undefined;
return;
}
restoreScroll(frames);
});
};
restoreScroll();
}, true);
};
export const useIsLayout = () => {
const page = useRouterPage();
return createMemo(() => page()?.type === "layout");
};