the page builder draws nothing on the page
A bar on every band, Edit / Spacing / Delete above every block, dotted outlines around blocks and columns, an add-zone at the end. Look at that and you are looking at the editor, not the page — on the screen whose whole purpose is judging the page. A click selects a block and its editor opens in the panel beside the preview, where every other control now lives. data-fofos-component stays: it is how the panel writes a draft into the page as somebody types. Adding a section, adding a block, deleting either and opening a spacing editor have no door any more. The handlers are untouched and nothing calls them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+49
-344
@@ -112,369 +112,74 @@ const style = (element: HTMLElement, rules: string[]) => {
|
|||||||
* Returns a cleanup. It runs before the next injection and on unmount — see `onDocument` in
|
* Returns a cleanup. It runs before the next injection and on unmount — see `onDocument` in
|
||||||
* SitePreview — so a reload never leaves two sets of controls behind.
|
* SitePreview — so a reload never leaves two sets of controls behind.
|
||||||
*/
|
*/
|
||||||
export function paintCanvas(document: Document, canvas: Canvas): () => void {
|
/**
|
||||||
const root = contentRoot(document);
|
* Selection, and NOTHING drawn.
|
||||||
if (!root) return () => {};
|
*
|
||||||
|
* ── What this replaced ──
|
||||||
// Anything a previous injection left. Cheap insurance: the cleanup should have run, and if a reload
|
*
|
||||||
// raced it, a second Add section button is exactly the sort of thing nobody reports as a bug.
|
* `paintCanvas` put a bar on every band with its name, its layout and a Delete; a row of Edit /
|
||||||
// Across the whole document, not just `main`: the measure guides are appended to body so they can be
|
* Spacing / Delete above every block; a dotted outline around every block and column; and an add-zone
|
||||||
// `fixed` without a positioned ancestor interfering.
|
* at the end of the page. Look at that and you are looking at the editor, not the page — on a screen
|
||||||
document.querySelectorAll(`[${MARK}]`).forEach((node) => node.remove());
|
* whose whole purpose is judging the page. Each was also a decision about type, spacing and colour
|
||||||
|
* that the site itself had not made.
|
||||||
/** Everything to undo, in the order it was done. */
|
*
|
||||||
|
* So this draws nothing at all: no elements, no stylesheet, no outlines. A click selects a block, and
|
||||||
|
* its editor opens in the panel beside the preview — where every other control now lives.
|
||||||
|
*
|
||||||
|
* ── The marker stays ──
|
||||||
|
*
|
||||||
|
* `data-fofos-component` is how the panel finds the block being edited so it can write a draft into it
|
||||||
|
* as somebody types. That is not chrome; it is the same kind of marker as `data-gutter`.
|
||||||
|
*
|
||||||
|
* ── What has no door any more ──
|
||||||
|
*
|
||||||
|
* Adding a section, adding a block, deleting either, and opening a spacing editor were all reached
|
||||||
|
* from the bars this removes. Their handlers still exist and still work; nothing calls them. They want
|
||||||
|
* a home in the editor column rather than painted onto the page.
|
||||||
|
*/
|
||||||
|
export function paintSelection(document: Document, canvas: Canvas): () => void {
|
||||||
const undo: (() => void)[] = [];
|
const undo: (() => void)[] = [];
|
||||||
|
|
||||||
/*
|
|
||||||
A stylesheet, for the one thing inline styles cannot express: `:hover`.
|
|
||||||
|
|
||||||
The component tools sat permanently at the top-right of whatever they belonged to, on top of the
|
|
||||||
text. On a paragraph that fills its column there is nowhere for them to go that is not on top of
|
|
||||||
something — so they are hidden until the component is hovered, which is when they are wanted and
|
|
||||||
the only time they are.
|
|
||||||
|
|
||||||
The outline goes the same way. An editor should show the page, with the handles arriving under the
|
|
||||||
cursor; an editor that outlines everything all the time is showing you the editor.
|
|
||||||
*/
|
|
||||||
const sheet = document.createElement('style');
|
|
||||||
sheet.setAttribute(MARK, 'stylesheet');
|
|
||||||
sheet.textContent = `
|
|
||||||
/*
|
|
||||||
EVERYTHING belonging to a section appears when that section is hovered.
|
|
||||||
|
|
||||||
Its outline, its name, its shape, its Delete, the outlines of its columns and the button that adds
|
|
||||||
to them. A page with four bands was four labelled boxes at rest, and the page underneath was the
|
|
||||||
one thing you could not see — which is what an editor is for.
|
|
||||||
|
|
||||||
Opacity rather than display, so nothing moves when it appears: the controls hold their space
|
|
||||||
whether or not they are visible, and the page does not reflow under the cursor.
|
|
||||||
*/
|
|
||||||
[data-section-id] {
|
|
||||||
outline: 1px solid transparent;
|
|
||||||
outline-offset: -1px;
|
|
||||||
transition: outline-color .12s;
|
|
||||||
}
|
|
||||||
[data-section-id]:hover { outline-color: ${ACCENT}; }
|
|
||||||
|
|
||||||
[${MARK}="section-bar"] { opacity: 0; transition: opacity .12s; }
|
|
||||||
[data-section-id]:hover [${MARK}="section-bar"] { opacity: 1; }
|
|
||||||
/* Keyboard reaches them too: focus inside a band is the same intent as hovering it. */
|
|
||||||
[data-section-id]:focus-within [${MARK}="section-bar"] { opacity: 1; }
|
|
||||||
|
|
||||||
[data-column] {
|
|
||||||
outline: 1px dashed transparent;
|
|
||||||
outline-offset: -5px;
|
|
||||||
transition: outline-color .12s;
|
|
||||||
}
|
|
||||||
[data-section-id]:hover [data-column] {
|
|
||||||
outline-color: color-mix(in srgb, ${ACCENT} 45%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
[${MARK}="add-component"] { opacity: 0; transition: opacity .12s; }
|
|
||||||
[data-section-id]:hover [${MARK}="add-component"] { opacity: 1; }
|
|
||||||
[${MARK}="add-component"]:focus { opacity: 1; }
|
|
||||||
|
|
||||||
/*
|
|
||||||
A COMPONENT's own handles need their own hover, not the section's. A band with three paragraphs
|
|
||||||
would otherwise show three sets of Edit and Delete at once, and none of them would say which.
|
|
||||||
*/
|
|
||||||
[data-fofos-component] { outline: 1px dotted transparent; transition: outline-color .12s; }
|
|
||||||
[data-fofos-component]:hover { outline-color: color-mix(in srgb, ${ACCENT} 55%, transparent); }
|
|
||||||
[${MARK}="component-tools"] { opacity: 0; transition: opacity .12s; }
|
|
||||||
[data-fofos-component]:hover > [${MARK}="component-tools"] { opacity: 1; }
|
|
||||||
[${MARK}="component-tools"]:focus-within { opacity: 1; }
|
|
||||||
`;
|
|
||||||
document.head.append(sheet);
|
|
||||||
undo.push(() => sheet.remove());
|
|
||||||
|
|
||||||
/*
|
|
||||||
The site's empty-page placeholder, hidden while the builder draws its own controls in that space.
|
|
||||||
|
|
||||||
Found by its marker rather than by position. Hidden rather than removed, so the cleanup puts it
|
|
||||||
back — a page whose last section is deleted shows it again without a reload.
|
|
||||||
*/
|
|
||||||
const placeholder = root.querySelector<HTMLElement>('[data-page-empty]');
|
|
||||||
if (placeholder) {
|
|
||||||
placeholder.style.display = 'none';
|
|
||||||
undo.push(() => {
|
|
||||||
placeholder.style.display = '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
Every section, outlined and named.
|
|
||||||
|
|
||||||
This is the whole reason the decoration exists: an EMPTY section renders as nothing, which is
|
|
||||||
correct on the public site and useless in an editor. Somebody who has just chosen "two columns"
|
|
||||||
needs to see two columns, not a blank page they have to take on trust.
|
|
||||||
*/
|
|
||||||
for (const section of canvas.sections) {
|
for (const section of canvas.sections) {
|
||||||
const element = root.querySelector<HTMLElement>(`[data-section-id="${section.id}"]`);
|
const element = document.querySelector<HTMLElement>(`[data-section-id="${section.id}"]`);
|
||||||
if (!element) continue;
|
if (!element) continue;
|
||||||
|
|
||||||
const previousPosition = element.style.position;
|
|
||||||
|
|
||||||
// `relative` so the label and the controls can be placed against this band rather than the page.
|
|
||||||
element.style.position = 'relative';
|
|
||||||
|
|
||||||
/*
|
|
||||||
NO margin. There was `2.5rem 0` here, to make room for the label above each band.
|
|
||||||
|
|
||||||
That is 40 pixels above and below every section that the real page does not have, and it made the
|
|
||||||
preview overstate the spacing of every page in the site — on the one screen whose entire purpose
|
|
||||||
is to show what the page looks like. It was reported as "there is a lot more space between the
|
|
||||||
logo and the text in the admin", which is exactly what it was.
|
|
||||||
|
|
||||||
A preview may draw ON the page. It may not CHANGE it. Everything this file adds is now either
|
|
||||||
absolutely positioned or an outline, both of which take no layout space, and the label moved
|
|
||||||
inside the band it names — see the bar below.
|
|
||||||
*/
|
|
||||||
undo.push(() => {
|
|
||||||
element.style.position = previousPosition;
|
|
||||||
});
|
|
||||||
|
|
||||||
const bar = document.createElement('div');
|
|
||||||
bar.setAttribute(MARK, 'section-bar');
|
|
||||||
style(bar, [
|
|
||||||
'position:absolute',
|
|
||||||
// INSIDE the band, not above it. Above needed 40px of margin the page does not have; the bar is
|
|
||||||
// hover-only, so overlaying the first line of a band for as long as the pointer is on it costs
|
|
||||||
// nothing and lying about the layout costs the preview its whole point.
|
|
||||||
'top:0',
|
|
||||||
'left:0',
|
|
||||||
'right:0',
|
|
||||||
'display:flex',
|
|
||||||
'align-items:center',
|
|
||||||
'gap:0.5rem',
|
|
||||||
'font-size:0.6875rem',
|
|
||||||
`color:${ACCENT}`,
|
|
||||||
// The page's own font would make this look like content. It is not content.
|
|
||||||
'font-family:ui-sans-serif, system-ui, sans-serif',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const label = document.createElement('span');
|
|
||||||
label.textContent = section.name;
|
|
||||||
style(label, ['font-weight:600']);
|
|
||||||
|
|
||||||
const shape = document.createElement('span');
|
|
||||||
shape.textContent = section.layout === 'two' ? 'two columns' : 'one column';
|
|
||||||
style(shape, ['opacity:0.7']);
|
|
||||||
|
|
||||||
/*
|
|
||||||
Spacing before Delete, and both on the right.
|
|
||||||
|
|
||||||
The order is how often they are wanted, not how they read: spacing is an ordinary adjustment and
|
|
||||||
deleting a band is not, so the destructive one sits furthest from where the hand arrives.
|
|
||||||
*/
|
|
||||||
const actions: HTMLElement[] = [];
|
|
||||||
for (const [text, run, first] of [
|
|
||||||
['Spacing', () => canvas.onSpaceSection(section), true],
|
|
||||||
['Delete', () => canvas.onDeleteSection(section), false],
|
|
||||||
] as const) {
|
|
||||||
const action = document.createElement('button');
|
|
||||||
action.type = 'button';
|
|
||||||
action.textContent = text;
|
|
||||||
style(action, [
|
|
||||||
first ? 'margin-left:auto' : '',
|
|
||||||
'cursor:pointer',
|
|
||||||
'background:none',
|
|
||||||
'border:0',
|
|
||||||
'padding:0',
|
|
||||||
'font:inherit',
|
|
||||||
'color:inherit',
|
|
||||||
'opacity:0.7',
|
|
||||||
'text-decoration:underline',
|
|
||||||
]);
|
|
||||||
action.addEventListener('click', run);
|
|
||||||
undo.push(() => action.removeEventListener('click', run));
|
|
||||||
actions.push(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
bar.append(label, shape, ...actions);
|
|
||||||
element.append(bar);
|
|
||||||
undo.push(() => bar.remove());
|
|
||||||
|
|
||||||
/*
|
|
||||||
The columns, outlined too.
|
|
||||||
|
|
||||||
An empty column has height and nothing else, so a two-column band and a one-column band look
|
|
||||||
identical until something is in them — which is exactly when it is too late to notice you chose
|
|
||||||
the wrong one.
|
|
||||||
*/
|
|
||||||
element.querySelectorAll<HTMLElement>('[data-column]').forEach((columnElement, columnIndex) => {
|
element.querySelectorAll<HTMLElement>('[data-column]').forEach((columnElement, columnIndex) => {
|
||||||
/*
|
|
||||||
Every component in this column gets a handle.
|
|
||||||
|
|
||||||
Drawn ON the rendered component rather than replacing it — an editor that swaps content for a
|
|
||||||
placeholder is showing you the editor, not the page. Free text with nothing written in it
|
|
||||||
renders as nothing, so the outline is the only thing saying it is there at all.
|
|
||||||
*/
|
|
||||||
const mine = section.components.filter((component) => component.column === columnIndex);
|
const mine = section.components.filter((component) => component.column === columnIndex);
|
||||||
|
|
||||||
for (const component of mine) {
|
for (const component of mine) {
|
||||||
|
// By position in the column, which is the order the API returns them in. The rendered node
|
||||||
|
// carries no id of its own.
|
||||||
const node = columnElement.children[mine.indexOf(component)] as HTMLElement | undefined;
|
const node = columnElement.children[mine.indexOf(component)] as HTMLElement | undefined;
|
||||||
if (!node) continue;
|
if (!node) continue;
|
||||||
|
|
||||||
const wasPosition = node.style.position;
|
|
||||||
node.style.position = 'relative';
|
|
||||||
// The outline and the tools' visibility are the stylesheet's, so they can depend on :hover.
|
|
||||||
// The ID, not an empty marker: the panel writes a draft into this node as somebody types, and
|
|
||||||
// has to be able to find the one being edited.
|
|
||||||
node.setAttribute('data-fofos-component', String(component.id));
|
node.setAttribute('data-fofos-component', String(component.id));
|
||||||
|
undo.push(() => node.removeAttribute('data-fofos-component'));
|
||||||
|
|
||||||
|
// A block the panel has no editor for is left alone: a click that opens nothing reads as a
|
||||||
|
// failure, which is worse than a click that does nothing. See canEditComponent.
|
||||||
|
if (canvas.canEditComponent && !canvas.canEditComponent(component)) continue;
|
||||||
|
|
||||||
|
const open = (event: MouseEvent) => {
|
||||||
|
// The page is real, so a block can hold a link and following it would leave the editor.
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
canvas.onEditComponent(component);
|
||||||
|
};
|
||||||
|
|
||||||
|
node.addEventListener('click', open);
|
||||||
|
const wasCursor = node.style.cursor;
|
||||||
|
node.style.cursor = 'pointer';
|
||||||
|
|
||||||
undo.push(() => {
|
undo.push(() => {
|
||||||
node.style.position = wasPosition;
|
node.removeEventListener('click', open);
|
||||||
node.removeAttribute('data-fofos-component');
|
node.style.cursor = wasCursor;
|
||||||
});
|
});
|
||||||
|
|
||||||
const tools = document.createElement('div');
|
|
||||||
tools.setAttribute(MARK, 'component-tools');
|
|
||||||
style(tools, [
|
|
||||||
'position:absolute',
|
|
||||||
// Above its own top-right corner rather than inside it, so even while visible it sits in the
|
|
||||||
// gap between components instead of over the first line of this one.
|
|
||||||
'top:-1.25rem',
|
|
||||||
'right:0',
|
|
||||||
'display:flex',
|
|
||||||
'gap:0.5rem',
|
|
||||||
'padding:0 0.25rem',
|
|
||||||
'font-size:0.625rem',
|
|
||||||
'font-family:ui-sans-serif, system-ui, sans-serif',
|
|
||||||
`color:${ACCENT}`,
|
|
||||||
'background:var(--site-bg, #fff)',
|
|
||||||
// Above the page, below the section bar, and never a target when invisible.
|
|
||||||
'z-index:5',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const actions: [string, () => void][] = [
|
|
||||||
['Spacing', () => canvas.onSpaceComponent(component)],
|
|
||||||
['Delete', () => canvas.onDeleteComponent(component)],
|
|
||||||
];
|
|
||||||
// First, so the tools read Edit / Spacing / Delete — the order they were in when Edit was
|
|
||||||
// unconditional, rather than the order the array happens to be built in.
|
|
||||||
if (canvas.canEditComponent?.(component) ?? true) {
|
|
||||||
actions.unshift(['Edit', () => canvas.onEditComponent(component)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [label, run] of actions) {
|
|
||||||
const action = document.createElement('button');
|
|
||||||
action.type = 'button';
|
|
||||||
action.textContent = label;
|
|
||||||
style(action, [
|
|
||||||
'cursor:pointer',
|
|
||||||
'background:none',
|
|
||||||
'border:0',
|
|
||||||
'padding:0',
|
|
||||||
'font:inherit',
|
|
||||||
'color:inherit',
|
|
||||||
'text-decoration:underline',
|
|
||||||
]);
|
|
||||||
action.addEventListener('click', run);
|
|
||||||
undo.push(() => action.removeEventListener('click', run));
|
|
||||||
tools.append(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
node.append(tools);
|
|
||||||
undo.push(() => tools.remove());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
And a way to put something in it.
|
|
||||||
|
|
||||||
One per column, always at the bottom, so a column that already has three paragraphs still has
|
|
||||||
somewhere obvious to add a fourth. Quieter than Add section — this is the inner loop, and a
|
|
||||||
button as loud as the one that adds a whole band would make every column shout.
|
|
||||||
*/
|
|
||||||
const add = document.createElement('button');
|
|
||||||
add.setAttribute(MARK, 'add-component');
|
|
||||||
add.type = 'button';
|
|
||||||
add.textContent = '+ Add component';
|
|
||||||
style(add, [
|
|
||||||
'display:block',
|
|
||||||
'width:100%',
|
|
||||||
'margin-top:0.75rem',
|
|
||||||
'padding:0.5rem',
|
|
||||||
'cursor:pointer',
|
|
||||||
'background:none',
|
|
||||||
`border:1px dashed color-mix(in srgb, ${ACCENT} 40%, transparent)`,
|
|
||||||
'border-radius:0.375rem',
|
|
||||||
`color:${ACCENT}`,
|
|
||||||
'font-family:ui-sans-serif, system-ui, sans-serif',
|
|
||||||
'font-size:0.75rem',
|
|
||||||
]);
|
|
||||||
const onAdd = () => canvas.onAddComponent(section, columnIndex);
|
|
||||||
add.addEventListener('click', onAdd);
|
|
||||||
columnElement.append(add);
|
|
||||||
undo.push(() => {
|
|
||||||
add.removeEventListener('click', onAdd);
|
|
||||||
add.remove();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// The measure. Drawn for anything that shows a page — see paintMeasure.
|
|
||||||
undo.push(paintMeasure(document));
|
|
||||||
|
|
||||||
// ── Add section, always last ─────────────────────────────────────────────────────────────────
|
|
||||||
/*
|
|
||||||
Add section goes at the END OF THE PAGE, held to the measure by hand.
|
|
||||||
|
|
||||||
It used to be appended to the content column, which was one element wrapping every section. It is
|
|
||||||
not one element any more: a built page is full-bleed so each band can draw a separator the width of
|
|
||||||
the window, and each holds its OWN content to the measure. Appending to the first thing matching
|
|
||||||
`[data-content-column]` would now put "add a section" inside section one.
|
|
||||||
|
|
||||||
So it goes in `main`, and the width comes from a column rather than from a constant — the measure is
|
|
||||||
Tailwind's `max-w-7xl` and this file has no business knowing that. With no section on the page there
|
|
||||||
is no column to ask, and full width is right anyway: there is nothing to line up with.
|
|
||||||
*/
|
|
||||||
const zoneParent = root;
|
|
||||||
const measure = root.querySelector<HTMLElement>('[data-content-column]')?.getBoundingClientRect().width;
|
|
||||||
|
|
||||||
const zone = document.createElement('div');
|
|
||||||
zone.setAttribute(MARK, 'add-section');
|
|
||||||
style(zone, [
|
|
||||||
'display:flex',
|
|
||||||
'align-items:center',
|
|
||||||
'justify-content:center',
|
|
||||||
// Tall when the page is empty, modest once there is something to add to — the zone should not
|
|
||||||
// dwarf the content it sits under.
|
|
||||||
canvas.sections.length ? 'min-height:8rem' : 'min-height:40vh',
|
|
||||||
'margin:2.5rem 0',
|
|
||||||
'border:2px dashed color-mix(in srgb, currentColor 25%, transparent)',
|
|
||||||
'border-radius:0.75rem',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const button = document.createElement('button');
|
|
||||||
button.type = 'button';
|
|
||||||
button.textContent = '+ Add section';
|
|
||||||
style(button, [
|
|
||||||
'cursor:pointer',
|
|
||||||
'padding:0.75rem 1.5rem',
|
|
||||||
'border-radius:0.5rem',
|
|
||||||
// The page's own colours, inverted, so the control belongs to whatever theme the site is wearing.
|
|
||||||
'background:var(--site-fg, #000)',
|
|
||||||
'color:var(--site-bg, #fff)',
|
|
||||||
'border:0',
|
|
||||||
'font:inherit',
|
|
||||||
'font-size:0.875rem',
|
|
||||||
]);
|
|
||||||
button.addEventListener('click', canvas.onAddSection);
|
|
||||||
|
|
||||||
zone.append(button);
|
|
||||||
// Aligned with the content rather than with the window, so the zone describes a place a section can
|
|
||||||
// actually go. `auto` margins do nothing until there is a width to centre.
|
|
||||||
if (measure) {
|
|
||||||
zone.style.maxWidth = `${Math.round(measure)}px`;
|
|
||||||
zone.style.marginInline = 'auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
zoneParent.append(zone);
|
|
||||||
undo.push(() => {
|
|
||||||
button.removeEventListener('click', canvas.onAddSection);
|
|
||||||
zone.remove();
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// Reversed, so a style put back is put back over the value that replaced it rather than under.
|
|
||||||
for (const step of undo.reverse()) step();
|
for (const step of undo.reverse()) step();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useClient } from 'hooks/useClient';
|
|||||||
import { readableError } from '../errors';
|
import { readableError } from '../errors';
|
||||||
import { SitePreview, useDevPort } from '../SitePreview';
|
import { SitePreview, useDevPort } from '../SitePreview';
|
||||||
import { usePreviewable, useViewMode } from '../ViewMode';
|
import { usePreviewable, useViewMode } from '../ViewMode';
|
||||||
import { paintCanvas, type CanvasComponent, type CanvasSection } from '../canvas';
|
import { paintSelection, type CanvasComponent, type CanvasSection } from '../canvas';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
What the Spacing dialog calls a block, by kind.
|
What the Spacing dialog calls a block, by kind.
|
||||||
@@ -163,7 +163,7 @@ export const PageBuilderPanel = () => {
|
|||||||
const onDocument = useCallback(
|
const onDocument = useCallback(
|
||||||
(frameDocument: Document) => {
|
(frameDocument: Document) => {
|
||||||
frameDoc.current = frameDocument;
|
frameDoc.current = frameDocument;
|
||||||
return paintCanvas(frameDocument, {
|
return paintSelection(frameDocument, {
|
||||||
sections,
|
sections,
|
||||||
onAddSection: () => handlersRef.current.add(),
|
onAddSection: () => handlersRef.current.add(),
|
||||||
onDeleteSection: (section) => handlersRef.current.remove(section.id),
|
onDeleteSection: (section) => handlersRef.current.remove(section.id),
|
||||||
|
|||||||
Reference in New Issue
Block a user