tweak-tools
Version:
Tweak your React projects until awesomeness
302 lines (301 loc) • 12.6 kB
JavaScript
;
var __rest = (this && this.__rest) || function (s, e) {
var t = {};
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0)
t[p] = s[p];
if (s != null && typeof Object.getOwnPropertySymbols === "function")
for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) {
if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i]))
t[p[i]] = s[p[i]];
}
return t;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.useCreateStore = exports.tweakStore = exports.Store = void 0;
const react_1 = require("react");
const zustand_1 = __importDefault(require("zustand"));
const middleware_1 = require("zustand/middleware");
const utils_1 = require("./utils");
const types_1 = require("./types");
const eventEmitter_1 = require("./eventEmitter");
exports.Store = function () {
const store = (0, zustand_1.default)((0, middleware_1.subscribeWithSelector)(() => ({ data: {} })));
const eventEmitter = (0, eventEmitter_1.createEventEmitter)();
this.storeId = (0, utils_1.getUid)();
this.useStore = store;
/**
* Folders will hold the folder settings for the pane.
* @note possibly make this reactive
*/
const folders = {};
/**
* OrderedPaths will hold all the paths in a parent -> children order.
* This will ensure we can display the controls in a predictable order.
*/
const orderedPaths = new Set();
/**
* For a given data structure, gets all paths for which inputs have
* a reference __refCount superior to zero. This function is used by the
* root pane to only display the inputs that are consumed by mounted
* components.
*
* @param data
*/
this.getVisiblePaths = () => {
const data = this.getData();
const paths = Object.keys(data);
// identifies hiddenFolders
const hiddenFolders = [];
Object.entries(folders).forEach(([path, settings]) => {
if (
// the folder settings have a render function
settings.render &&
// and the folder path matches a data path
// (this can happen on first mount and could probably be solved if folder settings
// were set together with the store data. In fact, the store data is set in useEffect
// while folders settings are set in useMemo).
paths.some((p) => p.indexOf(path) === 0) &&
// the folder settings is supposed to be hidden
!settings.render(this.get))
// then folder is hidden
hiddenFolders.push(path + '.');
});
const visiblePaths = [];
orderedPaths.forEach((path) => {
if (path in data &&
// if input is mounted
data[path].__refCount > 0 &&
// if it's not included in a hidden folder
hiddenFolders.every((p) => path.indexOf(p) === -1) &&
// if its render functions doesn't exists or returns true
(!data[path].render || data[path].render(this.get))) {
// then the input path is visible
visiblePaths.push(path);
}
});
return visiblePaths;
};
// adds paths to OrderedPaths
this.setOrderedPaths = (newPaths) => {
newPaths.forEach((p) => orderedPaths.add(p));
};
this.orderPaths = (paths) => {
this.setOrderedPaths(paths);
return paths;
};
/**
* When the useTweak hook unmmounts, it will call this function that will
* decrease the __refCount of all the inputs. When an input __refCount reaches 0, it
* should no longer be displayed in the panel.
*
* @param paths
*/
this.disposePaths = (paths) => {
store.setState((s) => {
const data = s.data;
paths.forEach((path) => {
if (path in data) {
const input = data[path];
input.__refCount--;
if (input.__refCount === 0 && input.type in types_1.SpecialInputs) {
// this makes sure special inputs such as buttons are properly
// refreshed. This might need some attention though.
delete data[path];
}
}
});
return { data };
});
};
this.dispose = () => {
store.setState(() => {
return { data: {} };
});
};
this.getFolderSettings = (path) => {
return folders[path] || {};
};
// Shorthand to get zustand store data
this.getData = () => {
return store.getState().data;
};
/**
* Merges the data passed as an argument with the store data.
* If an input path from the data already exists in the store,
* the function doesn't update the data but increments __refCount
* to keep track of how many components use that input key.
*
* Uses depsChanged to trigger a recompute and update inputs
* settings if needed.
*
* @param newData the data to update
* @param depsChanged to keep track of dependencies
*/
this.addData = (newData, override) => {
store.setState((s) => {
const data = s.data;
Object.entries(newData).forEach(([path, newInputData]) => {
let input = data[path];
// If an input already exists compare its values and increase the reference __refCount.
if (!!input) {
// @ts-ignore
const { type, value } = newInputData, rest = __rest(newInputData, ["type", "value"]);
if (type !== input.type) {
console.warn(utils_1.TweakErrors.INPUT_TYPE_OVERRIDE, input.type, type);
}
else {
if (input.__refCount === 0 || override) {
Object.assign(input, rest);
}
// Else we increment the ref count
input.__refCount++;
}
}
else {
data[path] = Object.assign(Object.assign({}, newInputData), { __refCount: 1 });
}
});
// Since we're returning a new object, direct mutation of data
// Should trigger a re-render so we're good!
return { data };
});
};
/**
* Shorthand function to set the value of an input at a given path.
*
* @param path path of the input
* @param value new value of the input
*/
this.setValueAtPath = (path, value, fromPanel) => {
store.setState((s) => {
const data = s.data;
//@ts-expect-error (we always update inputs with a value)
(0, utils_1.updateInput)(data[path], value, path, this, fromPanel);
return { data };
});
};
this.setSettingsAtPath = (path, settings) => {
store.setState((s) => {
const data = s.data;
//@ts-expect-error (we always update inputs with settings)
data[path].settings = Object.assign(Object.assign({}, data[path].settings), settings);
return { data };
});
};
this.disableInputAtPath = (path, flag) => {
store.setState((s) => {
const data = s.data;
//@ts-expect-error (we always update inputs with a value)
data[path].disabled = flag;
return { data };
});
};
this.set = (values, fromPanel) => {
store.setState((s) => {
const data = s.data;
Object.entries(values).forEach(([path, value]) => {
try {
//@ts-expect-error (we always update inputs with a value)
(0, utils_1.updateInput)(data[path], value, undefined, undefined, fromPanel);
}
catch (e) {
if (process.env.NODE_ENV === 'development') {
// eslint-disable-next-line no-console
console.warn(`[This message will only show in development]: \`set\` for path ${path} has failed.`, e);
}
}
});
return { data };
});
};
this.getInput = (path) => {
try {
return this.getData()[path];
}
catch (e) {
console.warn(utils_1.TweakErrors.PATH_DOESNT_EXIST, path, "null");
}
};
this.get = (path) => {
var _a;
return (_a = this.getInput(path)) === null || _a === void 0 ? void 0 : _a.value;
};
this.emitOnEditStart = (path) => {
eventEmitter.emit(`onEditStart:${path}`, this.get(path), path, Object.assign(Object.assign({}, this.getInput(path)), { get: this.get }));
};
this.emitOnEditEnd = (path) => {
eventEmitter.emit(`onEditEnd:${path}`, this.get(path), path, Object.assign(Object.assign({}, this.getInput(path)), { get: this.get }));
};
this.subscribeToEditStart = (path, listener) => {
const _path = `onEditStart:${path}`;
eventEmitter.on(_path, listener);
return () => eventEmitter.off(_path, listener);
};
this.subscribeToEditEnd = (path, listener) => {
const _path = `onEditEnd:${path}`;
eventEmitter.on(_path, listener);
return () => eventEmitter.off(_path, listener);
};
/**
* Recursively extract the data from the schema, sets folder initial
* preferences and normalize the inputs (normalizing an input means parsing the
* input object, identify its type and normalize its settings).
*
* @param schema
* @param rootPath used for recursivity
*/
const _getDataFromSchema = (schema, rootPath, mappedPaths) => {
const data = {};
Object.entries(schema).forEach(([key, rawInput]) => {
// if the key is empty, skip schema parsing and prompt an error.
if (key === '')
return console.warn(utils_1.TweakErrors.EMPTY_KEY, "", "");
let newPath = (0, utils_1.join)(rootPath, key);
// If the input is a folder, then we recursively parse its schema and assign
// it to the current data.
if (rawInput.type === types_1.SpecialInputs.FOLDER) {
const newData = _getDataFromSchema(rawInput.schema, newPath, mappedPaths);
Object.assign(data, newData);
// Sets folder preferences if it wasn't set before
if (!(newPath in folders))
folders[newPath] = rawInput.settings;
}
else if (key in mappedPaths) {
// if a key already exists, prompt an error.
console.warn(utils_1.TweakErrors.DUPLICATE_KEYS, key, newPath);
}
else {
const normalizedInput = (0, utils_1.normalizeInput)(rawInput, key, newPath, data);
if (normalizedInput) {
const { type, options, input } = normalizedInput;
// @ts-ignore
const { onChange, transient, onEditStart, onEditEnd } = options, _options = __rest(options, ["onChange", "transient", "onEditStart", "onEditEnd"]);
data[newPath] = Object.assign(Object.assign(Object.assign({ type }, _options), input), { fromPanel: true });
mappedPaths[key] = { path: newPath, onChange, transient, onEditStart, onEditEnd };
}
else {
console.warn(utils_1.TweakErrors.UNKNOWN_INPUT, newPath, rawInput);
}
}
});
return data;
};
this.getDataFromSchema = (schema) => {
const mappedPaths = {};
const data = _getDataFromSchema(schema, '', mappedPaths);
return [data, mappedPaths];
};
};
exports.tweakStore = new exports.Store();
function useCreateStore() {
return (0, react_1.useMemo)(() => new exports.Store(), []);
}
exports.useCreateStore = useCreateStore;
if (process.env.NODE_ENV === 'development' && typeof window !== 'undefined') {
// TODO remove store from window
// @ts-expect-error
window.__STORE = exports.tweakStore;
}