From aa9d7b370f9be1014d0de17b7b94b66dcd3602f0 Mon Sep 17 00:00:00 2001 From: Kevin F Date: Fri, 8 Mar 2024 14:17:18 +0100 Subject: [PATCH] Add FreeText cell editor :sparkles: #395 #390 --- src/Client/Client.fsproj | 1 + src/Client/Helper.fs | 5 + src/Client/MainComponents/ContextMenu.fs | 18 +- .../MainComponents/KeyboardShortcuts.fs | 21 +- src/Client/Modals/UpdateColumn.fs | 258 ++++++++++++++++++ .../Spreadsheet/Clipboard.Controller.fs | 17 ++ src/Client/Spreadsheet/Table.Controller.fs | 6 + src/Client/States/Spreadsheet.fs | 2 + src/Client/Update/SpreadsheetUpdate.fs | 8 + src/Client/Views/MainWindowView.fs | 1 - src/Shared/ARCtrl.Helper.fs | 14 + 11 files changed, 331 insertions(+), 20 deletions(-) create mode 100644 src/Client/Modals/UpdateColumn.fs diff --git a/src/Client/Client.fsproj b/src/Client/Client.fsproj index 342e58a1..0f97d662 100644 --- a/src/Client/Client.fsproj +++ b/src/Client/Client.fsproj @@ -54,6 +54,7 @@ + diff --git a/src/Client/Helper.fs b/src/Client/Helper.fs index 588305f1..5e43dcb5 100644 --- a/src/Client/Helper.fs +++ b/src/Client/Helper.fs @@ -62,6 +62,11 @@ type Navigator = [] let navigator : Navigator = jsNative +/// +/// take "count" many items from array if existing. if not enough items return as many as possible +/// +/// +/// let takeFromArray (count: int) (array: 'a []) = let exit (acc: 'a list) = List.rev acc |> Array.ofList let rec takeRec (l2: 'a list) (acc: 'a list) index = diff --git a/src/Client/MainComponents/ContextMenu.fs b/src/Client/MainComponents/ContextMenu.fs index 415d92d2..ff750526 100644 --- a/src/Client/MainComponents/ContextMenu.fs +++ b/src/Client/MainComponents/ContextMenu.fs @@ -17,6 +17,7 @@ type private ContextFunctions = { FillColumn : (Browser.Types.MouseEvent -> unit) -> Browser.Types.MouseEvent -> unit Clear : (Browser.Types.MouseEvent -> unit) -> Browser.Types.MouseEvent -> unit TransformCell : (Browser.Types.MouseEvent -> unit) -> Browser.Types.MouseEvent -> unit + UpdateAllCells : (Browser.Types.MouseEvent -> unit) -> Browser.Types.MouseEvent -> unit //EditColumn : (Browser.Types.MouseEvent -> unit) -> Browser.Types.MouseEvent -> unit RowIndex : int ColumnIndex : int @@ -42,7 +43,7 @@ let private contextmenu (mousex: int, mousey: int) (funcs:ContextFunctions) (con ] let button (name:string, icon: string, msg, props) = Html.li [ Bulma.button.button [ - prop.style [style.borderRadius 0; style.justifyContent.spaceBetween] + prop.style [style.borderRadius 0; style.justifyContent.spaceBetween; style.fontSize (length.rem 0.9)] prop.onClick msg prop.className "py-1" Bulma.button.isFullWidth @@ -57,14 +58,16 @@ let private contextmenu (mousex: int, mousey: int) (funcs:ContextFunctions) (con ] ] let divider = Html.li [ - Html.div [ prop.style [style.border(2, borderStyle.solid, NFDIColors.DarkBlue.Base); style.margin(2,0)] ] + Html.div [ prop.style [style.border(1, borderStyle.solid, NFDIColors.DarkBlue.Base); style.margin(2,0); style.width (length.perc 75); style.marginLeft length.auto] ] ] let buttonList = [ //button ("Edit Column", "fa-solid fa-table-columns", funcs.EditColumn rmv, []) - button ("Fill Column", "fa-solid fa-file-signature", funcs.FillColumn rmv, []) + button ("Fill Column", "fa-solid fa-pen", funcs.FillColumn rmv, []) if isUnitOrTermCell contextCell then let text = if contextCell.Value.isTerm then "As Unit Cell" else "As Term Cell" button (text, "fa-solid fa-arrow-right-arrow-left", funcs.TransformCell rmv, []) + else + button ("Update Column", "fa-solid fa-ellipsis-vertical", funcs.UpdateAllCells rmv, []) button ("Clear", "fa-solid fa-eraser", funcs.Clear rmv, []) divider button ("Copy", "fa-solid fa-copy", funcs.Copy rmv, []) @@ -110,6 +113,10 @@ let onContextMenu (index: int*int, model: Model, dispatch) = fun (e: Browser.Typ let isSelectedCell = model.SpreadsheetModel.SelectedCells.Contains index //let editColumnEvent _ = Modals.Controller.renderModal("EditColumn_Modal", Modals.EditColumn.Main (fst index) model dispatch) let triggerMoveColumnModal _ = Modals.Controller.renderModal("MoveColumn_Modal", Modals.MoveColumn.Main(fst index, model, dispatch)) + let triggerUpdateColumnModal _ = + let columnIndex = fst index + let column = model.SpreadsheetModel.ActiveTable.GetColumn columnIndex + Modals.Controller.renderModal("UpdateColumn_Modal", Modals.UpdateColumn.Main(fst index, column, dispatch)) let funcs = { DeleteRow = fun rmv e -> rmv e; deleteRowEvent e DeleteColumn = fun rmv e -> rmv e; Spreadsheet.DeleteColumn (fst index) |> Messages.SpreadsheetMsg |> dispatch @@ -117,19 +124,15 @@ let onContextMenu (index: int*int, model: Model, dispatch) = fun (e: Browser.Typ Copy = fun rmv e -> rmv e; if isSelectedCell then - log "Copy Cells" Spreadsheet.CopySelectedCells |> Messages.SpreadsheetMsg |> dispatch else - log "Copy Cell" Spreadsheet.CopyCell index |> Messages.SpreadsheetMsg |> dispatch Cut = fun rmv e -> rmv e; Spreadsheet.CutCell index |> Messages.SpreadsheetMsg |> dispatch Paste = fun rmv e -> rmv e; if isSelectedCell then - log "Paste Cells" Spreadsheet.PasteSelectedCells |> Messages.SpreadsheetMsg |> dispatch else - log "Paste Cell" Spreadsheet.PasteCell index |> Messages.SpreadsheetMsg |> dispatch PasteAll = fun rmv e -> rmv e; @@ -140,6 +143,7 @@ let onContextMenu (index: int*int, model: Model, dispatch) = fun (e: Browser.Typ if cell.IsSome && (cell.Value.isTerm || cell.Value.isUnitized) then let nextCell = if cell.Value.isTerm then cell.Value.ToUnitizedCell() else cell.Value.ToTermCell() rmv e; Spreadsheet.UpdateCell (index, nextCell) |> Messages.SpreadsheetMsg |> dispatch + UpdateAllCells = fun rmv e -> rmv e; triggerUpdateColumnModal e //EditColumn = fun rmv e -> rmv e; editColumnEvent e RowIndex = snd index ColumnIndex = fst index diff --git a/src/Client/MainComponents/KeyboardShortcuts.fs b/src/Client/MainComponents/KeyboardShortcuts.fs index 81046a28..4e53da79 100644 --- a/src/Client/MainComponents/KeyboardShortcuts.fs +++ b/src/Client/MainComponents/KeyboardShortcuts.fs @@ -4,6 +4,8 @@ let onKeydownEvent (dispatch: Messages.Msg -> unit) = fun (e: Browser.Types.Event) -> let e = e :?> Browser.Types.KeyboardEvent match e.ctrlKey, e.which with + | false, 27. | false, 13. | false, 9. | false, 16. -> // escape, enter, tab, shift + () | false, 46. -> // del Spreadsheet.ClearSelected |> Messages.SpreadsheetMsg |> dispatch | false, 37. -> // arrow left @@ -14,21 +16,16 @@ let onKeydownEvent (dispatch: Messages.Msg -> unit) = MoveSelectedCell Key.Right |> Messages.SpreadsheetMsg |> dispatch | false, 40. -> // arrow down MoveSelectedCell Key.Down |> Messages.SpreadsheetMsg |> dispatch - | false, key when key <> 27. && key <> 13 && key <> 9 -> // tab, escape, enter (not in this order :O) + | false, key -> SetActiveCellFromSelected |> Messages.SpreadsheetMsg |> dispatch - | false, _ -> - () // Ctrl + c | _, _ -> match (e.ctrlKey || e.metaKey), e.which with - // Ctrl + c - | true, 67. -> - Spreadsheet.CopySelectedCell |> Messages.SpreadsheetMsg |> dispatch - // Ctrl + x - | true, 88. -> - Spreadsheet.CutSelectedCell |> Messages.SpreadsheetMsg |> dispatch - // Ctrl + v - | true, 86. -> - Spreadsheet.PasteSelectedCell |> Messages.SpreadsheetMsg |> dispatch + | true, 67. -> // Ctrl + c + Spreadsheet.CopySelectedCells |> Messages.SpreadsheetMsg |> dispatch + | true, 88. -> // Ctrl + x + Spreadsheet.CutSelectedCells |> Messages.SpreadsheetMsg |> dispatch + | true, 86. -> // Ctrl + v + Spreadsheet.PasteSelectedCells |> Messages.SpreadsheetMsg |> dispatch | _, _ -> () diff --git a/src/Client/Modals/UpdateColumn.fs b/src/Client/Modals/UpdateColumn.fs new file mode 100644 index 00000000..69a229e6 --- /dev/null +++ b/src/Client/Modals/UpdateColumn.fs @@ -0,0 +1,258 @@ +namespace Modals + +open Feliz +open Feliz.Bulma +open Model +open Messages +open Shared +open OfficeInteropTypes + +open ARCtrl.ISA +open System.Text.RegularExpressions + +[] +type private FunctionPage = +| Create +| Update + +module private Components = + + open System + + let calculateRegex (regex:string) (input: string) = + try + let regex = Regex(regex) + let m = regex.Match(input) + match m.Success with + | true -> m.Index, m.Length + | false -> 0,0 + with + | _ -> 0,0 + + + let split (start: int) (length: int) (str: string) = + let s0, s1 = + str |> Seq.toList |> List.splitAt (start) + let s1, s2 = + s1 |> Seq.toList |> List.splitAt (length) + String.Join("", s0), String.Join("", s1), String.Join("", s2) + + let Tab(targetPage: FunctionPage, currentPage, setPage) = + Bulma.tab [ + if targetPage = currentPage then tab.isActive + prop.onClick (fun _ -> setPage targetPage) + prop.children [ + Html.a [ + prop.text (targetPage.ToString()) + ] + ] + ] + + let TabNavigation(currentPage, setPage) = + Bulma.tabs [ + prop.style [style.flexGrow 1] + tabs.isCentered + tabs.isFullWidth + prop.children [ + Html.ul [ + Tab(FunctionPage.Create, currentPage, setPage) + Tab(FunctionPage.Update, currentPage, setPage) + ] + ] + ] + + let PreviewRow(index:int,cell0: string, cell: string, markedIndices: int*int) = + Html.tr [ + Html.td index + Html.td [ + let s0,marked,s2 = split (fst markedIndices) (snd markedIndices) cell0 + Html.span s0 + Html.span [ + prop.className "has-background-info" + prop.text marked + ] + Html.span s2 + ] + Html.td (cell) + ] + + let PreviewTable(column: CompositeColumn, cellValues: string [], regex) = + Bulma.field.div [ + Bulma.label "Preview" + Bulma.tableContainer [ + Bulma.table [ + Html.thead [ + Html.tr [Html.th "";Html.th "Before"; Html.th "After"] + ] + Html.tbody [ + let previewCount = 5 + let preview = takeFromArray previewCount cellValues + for i in 0 .. (preview.Length-1) do + let cell0 = column.Cells.[i].ToString() + let cell = preview.[i] + let regexMarkedIndex = calculateRegex regex cell0 + PreviewRow(i,cell0,cell,regexMarkedIndex) + ] + ] + ] + ] + +type UpdateColumn = + + [] + static member private CreateForm(cellValues: string [], setPreview) = + let baseStr, setBaseStr = React.useState("") + let suffix, setSuffix = React.useState(false) + let updateCells (baseStr: string) (suffix:bool) = + cellValues + |> Array.mapi (fun i c -> + match suffix with + | true -> baseStr + string i + | false -> baseStr + ) + |> setPreview + Bulma.field.div [ + Bulma.field.div [ + Bulma.label "Base" + Bulma.input.text [ + prop.autoFocus true + prop.valueOrDefault baseStr + prop.onChange(fun s -> + setBaseStr s + updateCells s suffix + ) + ] + ] + Bulma.field.div [ + Bulma.control.div [ + Html.label [ + prop.className "is-flex is-align-items-center checkbox" + prop.style [style.gap (length.rem 0.5)] + prop.children [ + Html.input [ + prop.type' "checkbox" + prop.isChecked suffix + prop.onChange(fun e -> + setSuffix e + updateCells baseStr e + ) + ] + Bulma.help "Add number suffix" + ] + ] + ] + ] + ] + + [] + static member private UpdateForm(cellValues: string [], setPreview, regex: string, setRegex: string -> unit) = + let replacement, setReplacement = React.useState("") + let updateCells (replacement: string) (regex: string) = + if regex <> "" then + try + let regex = Regex(regex) + cellValues + |> Array.mapi (fun i c -> + let m = regex.Match(c) + match m.Success with + | true -> + let replaced = c.Replace(m.Value, replacement) + replaced + | false -> + c + ) + |> setPreview + with + | _ -> () + else + () + Bulma.field.div [ + Bulma.field.div [ + Html.div [ + prop.className "is-flex is-flex-direction-row" + prop.style [style.gap (length.rem 1)] + prop.children [ + Bulma.control.div [ + prop.style [style.flexGrow 1] + prop.children [ + Bulma.label "Regex" + Bulma.input.text [ + prop.autoFocus true + prop.valueOrDefault regex + prop.onChange (fun s -> + setRegex s; + updateCells replacement s + ) + ] + ] + ] + Bulma.control.div [ + prop.style [style.flexGrow 1] + prop.children [ + Bulma.label "Replacement" + Bulma.input.text [ + prop.valueOrDefault replacement + prop.onChange (fun s -> + setReplacement s; + updateCells s regex + ) + ] + ] + ] + ] + ] + ] + ] + + [] + static member Main(index: int, column: CompositeColumn, dispatch) (rmv: _ -> unit) = + let getCellStrings() = column.Cells |> Array.map (fun c -> c.ToString()) + let preview, setPreview = React.useState(getCellStrings) + let initPage = if preview.Length = 0 || preview |> String.concat "" = "" then FunctionPage.Create else FunctionPage.Update + let currentPage, setPage = React.useState(initPage) + /// This state is only used for update logic + let regex, setRegex = React.useState("") + let setPage = fun p -> + if p <> FunctionPage.Update then + setRegex "" + setPage p + let submit = fun () -> + preview + |> Array.map (fun x -> CompositeCell.FreeText x) + |> fun x -> CompositeColumn.create(column.Header, x) + |> fun x -> Spreadsheet.SetColumn (index, x) + |> SpreadsheetMsg + |> dispatch + Bulma.modal [ + Bulma.modal.isActive + prop.children [ + Bulma.modalBackground [ prop.onClick rmv ] + Bulma.modalCard [ + prop.style [style.maxHeight(length.percent 70); style.overflowY.hidden] + prop.children [ + Bulma.modalCardHead [ + Bulma.modalCardTitle "Update Column" + Bulma.delete [ prop.onClick rmv ] + ] + Bulma.modalCardBody [ + Components.TabNavigation(currentPage, setPage) + match currentPage with + | FunctionPage.Create -> UpdateColumn.CreateForm(getCellStrings(), setPreview) + | FunctionPage.Update -> UpdateColumn.UpdateForm(getCellStrings(), setPreview, regex, setRegex) + Components.PreviewTable(column, preview, regex) + ] + Bulma.modalCardFoot [ + Bulma.button.button [ + color.isInfo + prop.style [style.marginLeft length.auto] + prop.text "Submit" + prop.onClick(fun e -> + submit() + rmv e + ) + ] + ] + ] + ] + ] + ] diff --git a/src/Client/Spreadsheet/Clipboard.Controller.fs b/src/Client/Spreadsheet/Clipboard.Controller.fs index aac0a43d..9ba30d32 100644 --- a/src/Client/Spreadsheet/Clipboard.Controller.fs +++ b/src/Client/Spreadsheet/Clipboard.Controller.fs @@ -39,11 +39,28 @@ let cutCellByIndex (index: int*int) (state: Spreadsheet.Model) : Spreadsheet.Mod copyCell cell |> Promise.start state +let cutCellsByIndices (indices: (int*int) []) (state: Spreadsheet.Model) : Spreadsheet.Model = + log "HIT" + let cells = ResizeArray() + for index in indices do + let cell = state.ActiveTable.Values.[index] + // Remove selected cell value + let emptyCell = cell.GetEmptyCell() + state.ActiveTable.UpdateCellAt(fst index,snd index, emptyCell) + cells.Add(cell) + copyCells (Array.ofSeq cells) |> Promise.start + state + let cutSelectedCell (state: Spreadsheet.Model) : Spreadsheet.Model = /// Array.min is used until multiple cells are supported, should this ever be intended let index = state.SelectedCells |> Set.toArray |> Array.min cutCellByIndex index state +let cutSelectedCells (state: Spreadsheet.Model) : Spreadsheet.Model = + /// Array.min is used until multiple cells are supported, should this ever be intended + let indices = state.SelectedCells |> Set.toArray + cutCellsByIndices indices state + let pasteCellByIndex (index: int*int) (state: Spreadsheet.Model) : JS.Promise = promise { let! tab = navigator.clipboard.readText() diff --git a/src/Client/Spreadsheet/Table.Controller.fs b/src/Client/Spreadsheet/Table.Controller.fs index ba4a58c0..30ae7387 100644 --- a/src/Client/Spreadsheet/Table.Controller.fs +++ b/src/Client/Spreadsheet/Table.Controller.fs @@ -86,6 +86,12 @@ let deleteColumn (index: int) (state: Spreadsheet.Model) : Spreadsheet.Model = ArcFile = state.ArcFile SelectedCells = Set.empty} +let setColumn (index: int) (column: CompositeColumn) (state: Spreadsheet.Model) : Spreadsheet.Model = + state.ActiveTable.SetColumn (index, column) + {state with + ArcFile = state.ArcFile + SelectedCells = Set.empty} + let moveColumn (current: int) (next: int) (state: Spreadsheet.Model) : Spreadsheet.Model = state.ActiveTable.MoveColumn (current, next) {state with diff --git a/src/Client/States/Spreadsheet.fs b/src/Client/States/Spreadsheet.fs index 034f5d73..eb519afa 100644 --- a/src/Client/States/Spreadsheet.fs +++ b/src/Client/States/Spreadsheet.fs @@ -111,9 +111,11 @@ type Msg = | DeleteRow of int | DeleteRows of int [] | DeleteColumn of int +| SetColumn of index:int * column: CompositeColumn | CopySelectedCell | CopySelectedCells | CutSelectedCell +| CutSelectedCells | PasteSelectedCell | PasteSelectedCells | CopyCell of index:(int*int) diff --git a/src/Client/Update/SpreadsheetUpdate.fs b/src/Client/Update/SpreadsheetUpdate.fs index 8c2d036c..70f9b5fb 100644 --- a/src/Client/Update/SpreadsheetUpdate.fs +++ b/src/Client/Update/SpreadsheetUpdate.fs @@ -158,6 +158,9 @@ module Spreadsheet = | DeleteColumn index -> let nextState = Controller.deleteColumn index state nextState, model, Cmd.none + | SetColumn (index, column) -> + let nextState = Controller.setColumn index column state + nextState, model, Cmd.none | MoveColumn (current, next) -> let nextState = Controller.moveColumn current next state nextState, model, Cmd.none @@ -227,6 +230,11 @@ module Spreadsheet = if state.SelectedCells.IsEmpty then state else Controller.cutSelectedCell state nextState, model, Cmd.none + | CutSelectedCells -> + let nextState = + if state.SelectedCells.IsEmpty then state else + Controller.cutSelectedCells state + nextState, model, Cmd.none | PasteCell index -> let cmd = Cmd.OfPromise.either diff --git a/src/Client/Views/MainWindowView.fs b/src/Client/Views/MainWindowView.fs index 7022c502..5210b7eb 100644 --- a/src/Client/Views/MainWindowView.fs +++ b/src/Client/Views/MainWindowView.fs @@ -49,7 +49,6 @@ let private SpreadsheetSelectionFooter (model: Messages.Model) dispatch = Bulma.tab [ prop.style [style.width (length.px 20)] ] - MainComponents.FooterTabs.MainMetadata (model, dispatch) for index in 0 .. (model.SpreadsheetModel.Tables.TableCount-1) do MainComponents.FooterTabs.Main (index, model.SpreadsheetModel.Tables, model, dispatch) diff --git a/src/Shared/ARCtrl.Helper.fs b/src/Shared/ARCtrl.Helper.fs index 9a9ce70d..63a47903 100644 --- a/src/Shared/ARCtrl.Helper.fs +++ b/src/Shared/ARCtrl.Helper.fs @@ -78,6 +78,20 @@ module Extensions = Helper.dictMoveColumn currentIndex nextIndex this.Values () + member this.SetColumn(index: int, column: CompositeColumn) = + column.Validate(true) |> ignore + this.Headers.[index] <- column.Header + let cells = column.Cells + let keys = this.Values.Keys + for (ci, ri) in keys do + if ci = index then + let nextCell = cells |> Array.tryItem ri + match nextCell with + | Some c -> + this.Values.[(ci,ri)] <- c + | None -> + this.Values.[(ci,ri)] <- column.GetDefaultEmptyCell() + type Template with member this.FileName with get() = this.Name.Replace(" ","_") + ".xlsx"