rename packages, refactor out tailwind compiler,
This commit is contained in:
@@ -1686,8 +1686,8 @@ type AutoTableState struct {
|
||||
// thRefs memoizes header-cell refs, so a resize can measure them.
|
||||
thRefs map[string]*vdom.Ref
|
||||
|
||||
// openSignals memoizes per-dropdown open state (see openSignal).
|
||||
openSignals map[string]*vdom.Signal[bool]
|
||||
// multiSelects memoizes the dropdown controllers (see multiSelect).
|
||||
multiSelects map[string]*MultiSelect
|
||||
|
||||
// resolved by the last Render; kept so callers (export, toolbar actions) can ask
|
||||
// what the current filter actually selected.
|
||||
@@ -2116,34 +2116,33 @@ func (s *AutoTableState) MultiSelectSearch(identifier, placeholder string, value
|
||||
for _, v := range values {
|
||||
options = append(options, FormSelectOption{Value: v, Label: v})
|
||||
}
|
||||
open := s.openSignal(identifier)
|
||||
|
||||
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD),
|
||||
FormMultiSelect(FormMultiSelectProps{
|
||||
s.multiSelect(identifier).Render(FormMultiSelectProps{
|
||||
Options: options,
|
||||
Value: s.SearchValues(identifier),
|
||||
Placeholder: pick(placeholder, "Any"),
|
||||
Searchable: true,
|
||||
ShowSelectAll: true,
|
||||
Open: open.Get(),
|
||||
OnToggle: func() { open.Set(!open.Get()) },
|
||||
OnChange: func(vs []string) { s.SetSearchValues(identifier, vs, true) },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// openSignal memoizes the open state of a dropdown, keyed by identifier. It has to
|
||||
// be memoized: a signal created fresh inside a render would reset to closed on
|
||||
// every render, so the dropdown could never stay open.
|
||||
func (s *AutoTableState) openSignal(key string) *vdom.Signal[bool] {
|
||||
if s.openSignals == nil {
|
||||
s.openSignals = map[string]*vdom.Signal[bool]{}
|
||||
// multiSelect memoizes a dropdown controller, keyed by identifier.
|
||||
//
|
||||
// It MUST be memoized. A controller built inside a render would get fresh refs and a
|
||||
// fresh closed state every frame — the dropdown could never stay open, and the
|
||||
// outside-click listener would be watching an element that no longer exists.
|
||||
func (s *AutoTableState) multiSelect(key string) *MultiSelect {
|
||||
if s.multiSelects == nil {
|
||||
s.multiSelects = map[string]*MultiSelect{}
|
||||
}
|
||||
sig, ok := s.openSignals[key]
|
||||
ms, ok := s.multiSelects[key]
|
||||
if !ok {
|
||||
sig = vdom.NewSignal(false)
|
||||
s.openSignals[key] = sig
|
||||
ms = NewMultiSelect(DropdownOptions{})
|
||||
s.multiSelects[key] = ms
|
||||
}
|
||||
return sig
|
||||
return ms
|
||||
}
|
||||
|
||||
// DateSearch renders a date field bound to an identifier. A date RANGE is two of
|
||||
@@ -2719,17 +2718,13 @@ func (s *AutoTableState) ColumnPicker() *vdom.VNode {
|
||||
if len(options) == 0 {
|
||||
return nil
|
||||
}
|
||||
open := s.openSignal("__columns__")
|
||||
|
||||
return FormMultiSelect(FormMultiSelectProps{
|
||||
return s.multiSelect("__columns__").Render(FormMultiSelectProps{
|
||||
Options: options,
|
||||
Value: selected,
|
||||
Placeholder: "Columns",
|
||||
Searchable: true,
|
||||
ShowSelectAll: true,
|
||||
FieldWidth: "w-52",
|
||||
Open: open.Get(),
|
||||
OnToggle: func() { open.Set(!open.Get()) },
|
||||
OnChange: func(visible []string) {
|
||||
show := map[string]bool{}
|
||||
for _, k := range visible {
|
||||
@@ -3266,10 +3261,14 @@ type calcEditor struct {
|
||||
position *vdom.Signal[int]
|
||||
errorMsg *vdom.Signal[string]
|
||||
|
||||
opOpen *vdom.Signal[bool]
|
||||
colMenu *Menu
|
||||
fnMenu *Menu
|
||||
constMenu *Menu
|
||||
opSelect *MultiSelect // the Basic tab's operand picker
|
||||
|
||||
// The function menu's own search box.
|
||||
fnSearch *vdom.Signal[string]
|
||||
fnSearchRef *vdom.Ref
|
||||
colMenu *Menu
|
||||
fnMenu *Menu
|
||||
constMenu *Menu
|
||||
|
||||
// The textarea and the highlight overlay behind it. Both are needed: the caret
|
||||
// insert writes into the textarea, and the overlay's scroll has to follow it.
|
||||
@@ -3282,27 +3281,40 @@ func (s *AutoTableState) calcEditorState() *calcEditor {
|
||||
return s.editor
|
||||
}
|
||||
e := &calcEditor{
|
||||
view: vdom.NewSignal("menu"),
|
||||
editingID: vdom.NewSignal(""),
|
||||
advanced: vdom.NewSignal(false),
|
||||
name: vdom.NewSignal(""),
|
||||
fn: vdom.NewSignal(string(CALC_FN_SUM)),
|
||||
operands: vdom.NewSignal([]string{}),
|
||||
formula: vdom.NewSignal(""),
|
||||
dataType: vdom.NewSignal(string(CALC_TYPE_NUMBER)),
|
||||
precision: vdom.NewSignal(""),
|
||||
position: vdom.NewSignal(int(COL_POS_RIGHT)),
|
||||
errorMsg: vdom.NewSignal(""),
|
||||
opOpen: vdom.NewSignal(false),
|
||||
formulaRef: vdom.NewRef(),
|
||||
overlayRef: vdom.NewRef(),
|
||||
view: vdom.NewSignal("menu"),
|
||||
editingID: vdom.NewSignal(""),
|
||||
advanced: vdom.NewSignal(false),
|
||||
name: vdom.NewSignal(""),
|
||||
fn: vdom.NewSignal(string(CALC_FN_SUM)),
|
||||
operands: vdom.NewSignal([]string{}),
|
||||
formula: vdom.NewSignal(""),
|
||||
dataType: vdom.NewSignal(string(CALC_TYPE_NUMBER)),
|
||||
precision: vdom.NewSignal(""),
|
||||
position: vdom.NewSignal(int(COL_POS_RIGHT)),
|
||||
errorMsg: vdom.NewSignal(""),
|
||||
opSelect: NewMultiSelect(DropdownOptions{}),
|
||||
fnSearch: vdom.NewSignal(""),
|
||||
fnSearchRef: vdom.NewRef(),
|
||||
formulaRef: vdom.NewRef(),
|
||||
overlayRef: vdom.NewRef(),
|
||||
// Standalone: these menus live INSIDE the editor's popover, and without it the
|
||||
// single-open manager would read them as a rival panel and close their own
|
||||
// parent as they opened.
|
||||
colMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
|
||||
fnMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
|
||||
constMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
|
||||
}
|
||||
// Built after e exists, because it closes over it: closing the menu abandons the
|
||||
// query. Reopening to a stale filter — four of thirty-five functions showing, for
|
||||
// no visible reason — is worse than retyping.
|
||||
e.fnMenu = NewMenu(MenuOptions{
|
||||
Placement: PlacementBottomStart,
|
||||
Standalone: true,
|
||||
OnOpenChange: func(open bool) {
|
||||
if !open {
|
||||
e.fnSearch.Set("")
|
||||
}
|
||||
},
|
||||
})
|
||||
e.pop = NewPopover(PopoverProps{
|
||||
Placement: PlacementBottomEnd,
|
||||
// Closing the popover abandons the draft — reopening should start clean rather
|
||||
@@ -3382,12 +3394,124 @@ var calcDataTypes = []struct {
|
||||
{CALC_TYPE_PLAIN, "Plain"},
|
||||
}
|
||||
|
||||
// FormulaFunctionNames are the functions a formula may call, for the insert menu.
|
||||
var FormulaFunctionNames = []string{
|
||||
"SUM", "AVERAGE", "MEDIAN", "MODE", "MIN", "MAX", "COUNT",
|
||||
"ABS", "ROUND", "FLOOR", "CEILING", "SQRT", "POWER", "MOD", "EXP", "LN", "LOG",
|
||||
"SIN", "COS", "TAN", "ASIN", "ACOS", "ATAN", "ATAN2", "SINH", "COSH", "TANH",
|
||||
"RADIANS", "DEGREES", "IF", "AND", "OR", "NOT", "ROW",
|
||||
// FormulaFunction is one entry in the insert menu: what to type, what it takes, and
|
||||
// what it does. The signature and the description are not decoration — they are what
|
||||
// makes a list of thirty-five names usable by someone who does not already know them,
|
||||
// and they are what the menu's search matches against.
|
||||
type FormulaFunction struct {
|
||||
Name string
|
||||
Sig string
|
||||
Desc string
|
||||
}
|
||||
|
||||
// FormulaFunctionGroup is a category of functions.
|
||||
type FormulaFunctionGroup struct {
|
||||
Label string
|
||||
Fns []FormulaFunction
|
||||
}
|
||||
|
||||
// FormulaFunctionGroups are the functions a formula may call, grouped the way someone
|
||||
// looking for one would think about them — by what they are FOR, not alphabetically.
|
||||
// Someone who wants a total looks under Aggregate; nobody scans an A-to-Z list from
|
||||
// ABS to TANH hoping to recognise something.
|
||||
var FormulaFunctionGroups = []FormulaFunctionGroup{
|
||||
{Label: "Aggregate", Fns: []FormulaFunction{
|
||||
{"SUM", "SUM(range)", "Total of the values"},
|
||||
{"AVERAGE", "AVERAGE(range)", "Mean of the values"},
|
||||
{"MEDIAN", "MEDIAN(range)", "Middle value"},
|
||||
{"MODE", "MODE(range)", "Most frequent value"},
|
||||
{"MIN", "MIN(range)", "Smallest value"},
|
||||
{"MAX", "MAX(range)", "Largest value"},
|
||||
{"COUNT", "COUNT(range)", "How many numbers"},
|
||||
}},
|
||||
{Label: "Math", Fns: []FormulaFunction{
|
||||
{"ABS", "ABS(n)", "Absolute value"},
|
||||
{"ROUND", "ROUND(n, digits)", "Round to digits"},
|
||||
{"FLOOR", "FLOOR(n)", "Round down"},
|
||||
{"CEILING", "CEILING(n)", "Round up"},
|
||||
{"SQRT", "SQRT(n)", "Square root"},
|
||||
{"POWER", "POWER(n, p)", "n to the power p"},
|
||||
{"MOD", "MOD(n, d)", "Remainder of n ÷ d"},
|
||||
{"EXP", "EXP(n)", "e to the power n"},
|
||||
{"LN", "LN(n)", "Natural log (base e)"},
|
||||
{"LOG", "LOG(n, base)", "Log, base 10 by default"},
|
||||
}},
|
||||
{Label: "Trigonometry", Fns: []FormulaFunction{
|
||||
{"SIN", "SIN(angle)", "Sine (radians)"},
|
||||
{"COS", "COS(angle)", "Cosine (radians)"},
|
||||
{"TAN", "TAN(angle)", "Tangent (radians)"},
|
||||
{"ASIN", "ASIN(n)", "Inverse sine"},
|
||||
{"ACOS", "ACOS(n)", "Inverse cosine"},
|
||||
{"ATAN", "ATAN(n)", "Inverse tangent"},
|
||||
{"ATAN2", "ATAN2(x, y)", "Angle of point (x, y)"},
|
||||
{"SINH", "SINH(n)", "Hyperbolic sine"},
|
||||
{"COSH", "COSH(n)", "Hyperbolic cosine"},
|
||||
{"TANH", "TANH(n)", "Hyperbolic tangent"},
|
||||
{"PI", "PI()", "π constant"},
|
||||
{"RADIANS", "RADIANS(deg)", "Degrees → radians"},
|
||||
{"DEGREES", "DEGREES(rad)", "Radians → degrees"},
|
||||
}},
|
||||
{Label: "Logic", Fns: []FormulaFunction{
|
||||
{"IF", "IF(test, then, else)", "Choose by condition"},
|
||||
{"AND", "AND(a, b, …)", "True if all are true"},
|
||||
{"OR", "OR(a, b, …)", "True if any are true"},
|
||||
{"NOT", "NOT(a)", "Negate"},
|
||||
}},
|
||||
{Label: "Row", Fns: []FormulaFunction{
|
||||
{"ROW", "ROW()", "Current row number"},
|
||||
}},
|
||||
}
|
||||
|
||||
// FormulaConstant is a named constant, with what it is worth.
|
||||
type FormulaConstant struct {
|
||||
Name string
|
||||
Desc string
|
||||
}
|
||||
|
||||
// FormulaConstantOptions are the constants a formula may write bare, for the insert
|
||||
// menu. The approximate value is shown because "PHI" tells you nothing and "≈ 1.618"
|
||||
// tells you everything.
|
||||
var FormulaConstantOptions = []FormulaConstant{
|
||||
{"PI", "π ≈ 3.14159"},
|
||||
{"E", "Euler's number ≈ 2.71828"},
|
||||
{"TAU", "2π ≈ 6.28319"},
|
||||
{"PHI", "Golden ratio ≈ 1.61803"},
|
||||
{"SQRT2", "√2 ≈ 1.41421"},
|
||||
}
|
||||
|
||||
// FormulaFunctionNames flattens the groups — for anything that just needs the names.
|
||||
func FormulaFunctionNames() []string {
|
||||
var out []string
|
||||
for _, g := range FormulaFunctionGroups {
|
||||
for _, f := range g.Fns {
|
||||
out = append(out, f.Name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// filterFormulaGroups narrows the menu by a query, matching name, signature AND
|
||||
// description — so "total" finds SUM, which is the whole point of carrying the prose
|
||||
// around. Empty groups drop out rather than leaving a bare heading behind.
|
||||
func filterFormulaGroups(query string) []FormulaFunctionGroup {
|
||||
q := strings.TrimSpace(strings.ToLower(query))
|
||||
if q == "" {
|
||||
return FormulaFunctionGroups
|
||||
}
|
||||
var out []FormulaFunctionGroup
|
||||
for _, g := range FormulaFunctionGroups {
|
||||
var fns []FormulaFunction
|
||||
for _, f := range g.Fns {
|
||||
hay := strings.ToLower(f.Name + " " + f.Sig + " " + f.Desc)
|
||||
if strings.Contains(hay, q) {
|
||||
fns = append(fns, f)
|
||||
}
|
||||
}
|
||||
if len(fns) > 0 {
|
||||
out = append(out, FormulaFunctionGroup{Label: g.Label, Fns: fns})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// operandOption is one entry in the operand pickers and the column insert menu: the
|
||||
@@ -3743,14 +3867,12 @@ func (s *AutoTableState) calcBasicEditor(e *calcEditor, summary bool) *vdom.VNod
|
||||
msOpts = append(msOpts, FormSelectOption{Value: o.Key, Label: o.Label})
|
||||
}
|
||||
children = append(children, calcField("Columns (combined per row)",
|
||||
FormMultiSelect(FormMultiSelectProps{
|
||||
e.opSelect.Render(FormMultiSelectProps{
|
||||
Options: msOpts,
|
||||
Value: e.operands.Get(),
|
||||
Placeholder: "Select columns…",
|
||||
Searchable: true,
|
||||
Small: true,
|
||||
Open: e.opOpen.Get(),
|
||||
OnToggle: func() { e.opOpen.Set(!e.opOpen.Get()) },
|
||||
OnChange: func(v []string) { e.operands.Set(v) },
|
||||
})))
|
||||
}
|
||||
@@ -3796,25 +3918,45 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
)
|
||||
}
|
||||
|
||||
fnItems := make([]*vdom.VNode, 0, len(FormulaFunctionNames))
|
||||
for _, name := range FormulaFunctionNames {
|
||||
fname := name
|
||||
fnItems = append(fnItems, e.fnMenu.Item(MenuItemProps{
|
||||
OnClick: func() { e.insertAtCaret(fname + "()") },
|
||||
}, vdom.Span(vdom.Attr("class", "font-mono text-emerald-700"), vdom.Text(fname))))
|
||||
// The function menu, grouped by category and searchable. Thirty-five names in one
|
||||
// flat list is a wall; grouped by what they are FOR, with a signature and a
|
||||
// sentence, it is something you can actually find SUM in.
|
||||
fnItems := []*vdom.VNode{e.fnSearchBox()}
|
||||
groups := filterFormulaGroups(e.fnSearch.Get())
|
||||
if len(groups) == 0 {
|
||||
fnItems = append(fnItems, vdom.Div(
|
||||
vdom.Attr("class", "px-2 py-3 text-center text-xs text-neutral-500"),
|
||||
vdom.Text("No functions match"),
|
||||
))
|
||||
}
|
||||
for _, g := range groups {
|
||||
fnItems = append(fnItems, MenuSection("", vdom.Text(g.Label)))
|
||||
for _, fn := range g.Fns {
|
||||
f := fn
|
||||
fnItems = append(fnItems, e.fnMenu.Item(MenuItemProps{
|
||||
OnClick: func() { e.insertFunction(f) },
|
||||
},
|
||||
vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5 min-w-0"),
|
||||
// The signature is syntax-highlighted with the same highlighter the
|
||||
// formula box uses, so the menu and the editor speak one language.
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-xs"), vdom.Raw(HighlightFormula(f.Sig))),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(f.Desc)),
|
||||
),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
constNames := make([]string, 0, len(FormulaConstants))
|
||||
for name := range FormulaConstants {
|
||||
constNames = append(constNames, name)
|
||||
}
|
||||
sort.Strings(constNames)
|
||||
constItems := make([]*vdom.VNode, 0, len(constNames))
|
||||
for _, name := range constNames {
|
||||
cname := name
|
||||
constItems := make([]*vdom.VNode, 0, len(FormulaConstantOptions))
|
||||
for _, c := range FormulaConstantOptions {
|
||||
cc := c
|
||||
constItems = append(constItems, e.constMenu.Item(MenuItemProps{
|
||||
OnClick: func() { e.insertAtCaret(cname) },
|
||||
}, vdom.Span(vdom.Attr("class", "font-mono text-amber-600"), vdom.Text(cname))))
|
||||
OnClick: func() { e.insertAtCaret(cc.Name) },
|
||||
},
|
||||
vdom.Div(vdom.Attr("class", "flex flex-col items-start gap-0.5"),
|
||||
vdom.Span(vdom.Attr("class", "font-mono text-xs text-amber-600"), vdom.Text(cc.Name)),
|
||||
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(cc.Desc)),
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
menus := vdom.Div(vdom.Attr("class", "flex flex-wrap items-center gap-1"),
|
||||
@@ -3876,6 +4018,44 @@ func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.V
|
||||
return calcField("Formula", children...)
|
||||
}
|
||||
|
||||
// fnSearchBox filters the function menu. It is sticky, so it stays reachable as the
|
||||
// list scrolls, and it is focused when the menu opens — you can type "total", see SUM,
|
||||
// and never touch the mouse.
|
||||
func (e *calcEditor) fnSearchBox() *vdom.VNode {
|
||||
wasmruntime.AfterRender(func() { wasmruntime.Focus(e.fnSearchRef) })
|
||||
|
||||
return vdom.Div(vdom.Attr("class", "sticky top-0 z-10 -m-1.5 mb-1 border-b border-neutral-200 bg-white p-1.5"),
|
||||
vdom.Input(
|
||||
vdom.WithRef(e.fnSearchRef),
|
||||
vdom.Attr("type", "text"),
|
||||
vdom.Attr("class", "w-full rounded-default border border-neutral-300 p-1 text-xs focus:outline-2 focus:outline-sky-500"),
|
||||
vdom.Attr("placeholder", "Search functions…"),
|
||||
vdom.Attr("spellcheck", "false"),
|
||||
vdom.Prop("value", e.fnSearch.Get()),
|
||||
vdom.OnEvent(vdom.EVENT_INPUT, func(ev vdom.Event) { e.fnSearch.Set(ev.Value()) }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// insertFunction drops a call in and puts the caret INSIDE the parentheses, ready for
|
||||
// the first argument — SUM() with the cursor after the "(" rather than after the ")",
|
||||
// which would make you move it back every single time.
|
||||
//
|
||||
// A no-argument function (ROW(), PI()) has nothing to type, so the caret goes after it.
|
||||
func (e *calcEditor) insertFunction(f FormulaFunction) {
|
||||
noArgs := strings.HasSuffix(f.Sig, "()")
|
||||
e.insertAtCaretOffset(f.Name+"()", boolToInt(!noArgs))
|
||||
e.fnSearch.Set("")
|
||||
e.fnMenu.Close()
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// syncOverlayScroll keeps the colours under the caret. Imperative on purpose: this
|
||||
// fires on every scroll frame, and a signal write would re-render the whole table.
|
||||
func (e *calcEditor) syncOverlayScroll() {
|
||||
@@ -3916,7 +4096,11 @@ func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *v
|
||||
// insertAtCaret drops text where the cursor is, rather than at the end — which is
|
||||
// the whole point of an insert menu. It needs the caret position and focus back
|
||||
// afterwards, both of which come from the host API.
|
||||
func (e *calcEditor) insertAtCaret(text string) {
|
||||
func (e *calcEditor) insertAtCaret(text string) { e.insertAtCaretOffset(text, 0) }
|
||||
|
||||
// insertAtCaretOffset inserts text and leaves the caret `back` characters from its
|
||||
// end — so a function can land with the cursor between its parentheses.
|
||||
func (e *calcEditor) insertAtCaretOffset(text string, back int) {
|
||||
src := e.formula.Get()
|
||||
start, end := wasmruntime.SelectionRange(e.formulaRef)
|
||||
if start < 0 || start > len(src) || end < start || end > len(src) {
|
||||
@@ -3927,7 +4111,7 @@ func (e *calcEditor) insertAtCaret(text string) {
|
||||
|
||||
// The textarea's value is written by the render this signal just scheduled, so the
|
||||
// caret can only be placed once that render has landed.
|
||||
caret := start + len(text)
|
||||
caret := start + len(text) - back
|
||||
wasmruntime.AfterRender(func() {
|
||||
wasmruntime.Focus(e.formulaRef)
|
||||
wasmruntime.SetSelectionRange(e.formulaRef, caret, caret)
|
||||
|
||||
Reference in New Issue
Block a user