diff --git a/code/modules/modular_computers/file_system/computer_file.dm b/code/modules/modular_computers/file_system/computer_file.dm index 527ce2161bd..dbf457293dd 100644 --- a/code/modules/modular_computers/file_system/computer_file.dm +++ b/code/modules/modular_computers/file_system/computer_file.dm @@ -5,6 +5,7 @@ GLOBAL_VAR_INIT(file_uid, 0) var/filename = "NewFile" /// File full names are [filename].[filetype] so like NewFile.XXX in this case var/filetype = "XXX" + /// Description of the file, shown in file browser. var/filedesc = null /// File size in GQ. Integers only! var/size = 1 @@ -46,6 +47,8 @@ GLOBAL_VAR_INIT(file_uid, 0) temp.filename = filename + "(Copy)" else temp.filename = filename + if(filedesc) + temp.filedesc = filedesc temp.filetype = filetype return temp diff --git a/code/modules/modular_computers/file_system/programs/system/file_browser.dm b/code/modules/modular_computers/file_system/programs/system/file_browser.dm index 0d993f0e4a4..5750a6c3bd9 100644 --- a/code/modules/modular_computers/file_system/programs/system/file_browser.dm +++ b/code/modules/modular_computers/file_system/programs/system/file_browser.dm @@ -1,4 +1,9 @@ #define MAX_TEXTFILE_LENGTH 128000 // 512GQ file +#define FMS_FILEBROWSER 0 +#define FMS_SHOWFILE 1 +#define FMS_FORMS 2 +#define FMS_EDIT 3 + /datum/computer_file/program/filemanager filename = "filemanager" filedesc = "NTOS File Manager" @@ -11,6 +16,10 @@ available_on_ntnet = FALSE undeletable = TRUE tgui_id = "FileManager" + //Current screen of file manager. + var/screen = FMS_FILEBROWSER + //Department filter for forms browser. + var/sql_filter_dept = "" var/open_file var/open_file_is_usb = FALSE var/error @@ -21,61 +30,89 @@ var/obj/item/computer_hardware/hard_drive/HDD var/obj/item/computer_hardware/hard_drive/portable/RHDD data["error"] = error + data["screen"] = screen + + if(!computer || !computer.hard_drive) + data["error"] = "I/O ERROR: Unable to access hard drive." + return data + if(screen == FMS_FILEBROWSER) + data["script_data"] = null + data["file_data"] = null + data["file_is_usb"] = FALSE + data["file_name"] = null + HDD = computer.hard_drive + RHDD = computer.portable_drive + var/list/files = list() + for(var/datum/computer_file/F in HDD.stored_files) + files.Add(list(list( + "name" = F.filename, + "type" = F.filetype, + "desc" = F.filedesc, + "size" = F.size, + "undeletable" = F.undeletable, + "password" = !!F.password + ))) + data["files"] = files + if(RHDD) + data["usb_connected"] = TRUE + var/list/usb_files = list() + for(var/datum/computer_file/F in RHDD.stored_files) + usb_files.Add(list(list( + "name" = F.filename, + "type" = F.filetype, + "desc" = F.filedesc, + "size" = F.size, + "undeletable" = F.undeletable, + "password" = !!F.password + ))) + data["usb_files"] = usb_files + else + data["usb_connected"] = FALSE + if(screen == FMS_FORMS) + if(!SSdbcore.Connect()) + data["sql_error"] = 1 + else + var/datum/db_query/query + if(sql_filter_dept) + query = SSdbcore.NewQuery( + "SELECT id, name, department FROM ss13_forms WHERE department LIKE :filter ORDER BY id", + list("filter" = "%[sql_filter_dept]%")) + else + query = SSdbcore.NewQuery("SELECT id, name, department FROM ss13_forms ORDER BY id") + if(!query.Execute()) + data["sql_error"] = 1 + else + var/list/forms = list() + while(query.NextRow()) + forms += list(list("id" = query.item[1], "name" = query.item[2], "department" = query.item[3])) + if(!forms.len) + data["sql_error"] = 1 + data["forms"] = forms + qdel(query) if(open_file) var/datum/computer_file/data/file var/datum/computer_file/script/script - - if(!computer || !computer.hard_drive) - data["error"] = "I/O ERROR: Unable to access hard drive." - else - HDD = open_file_is_usb ? computer.portable_drive : computer.hard_drive - data["file_is_usb"] = open_file_is_usb - if(!HDD) + HDD = open_file_is_usb ? computer.portable_drive : computer.hard_drive + data["file_is_usb"] = open_file_is_usb + if(!HDD) + data["error"] = "I/O ERROR: Unable to open file." + return data + file = HDD.find_file_by_name(open_file) + script = file + data["file_name"] = "[file.filename]" + data["file_type"] = "[file.filetype]" + data["file_desc"] = "[file.filedesc]" + if(!istype(file)) + if(!istype(script)) data["error"] = "I/O ERROR: Unable to open file." return data - file = HDD.find_file_by_name(open_file) - script = file - if(!istype(file)) - if(!istype(script)) - data["error"] = "I/O ERROR: Unable to open file." - else - data["scriptdata"] = html_encode(script.code) - data["filename"] = "[script.filename].[script.filetype]" else - data["filedata"] = pencode2html(file.stored_data) - data["filename"] = "[file.filename].[file.filetype]" - else - if(!computer || !computer.hard_drive) - data["error"] = "I/O ERROR: Unable to access hard drive." + data["script_data"] = html_encode(script.code) + return data + if(screen == FMS_EDIT) + data["file_data"] = file.stored_data else - data["scriptdata"] = null - data["filedata"] = null - data["file_is_usb"] = FALSE - data["filename"] = null - HDD = computer.hard_drive - RHDD = computer.portable_drive - var/list/files = list() - for(var/datum/computer_file/F in HDD.stored_files) - files.Add(list(list( - "name" = F.filename, - "type" = F.filetype, - "size" = F.size, - "undeletable" = F.undeletable, - "encrypted" = !!F.password - ))) - data["files"] = files - if(RHDD) - data["usbconnected"] = TRUE - var/list/usbfiles = list() - for(var/datum/computer_file/F in RHDD.stored_files) - usbfiles.Add(list(list( - "name" = F.filename, - "type" = F.filetype, - "size" = F.size, - "undeletable" = F.undeletable, - "encrypted" = !!F.password - ))) - data["usbfiles"] = usbfiles + data["file_data"] = pencode2html(file.stored_data) return data /datum/computer_file/program/filemanager/ui_act(action, list/params, datum/tgui/ui, datum/ui_state/state) @@ -83,68 +120,87 @@ return switch(action) - if("PRG_openfile") + if("set_screen") + . = TRUE + var/new_screen = text2num(params["screen"]) + if(new_screen == FMS_EDIT) + if(!open_file) + return TRUE + var/obj/item/computer_hardware/hard_drive/HDD = computer.hard_drive + if(!HDD) + return TRUE + var/datum/computer_file/data/F = HDD.find_file_by_name(open_file) + if(!F || !istype(F)) + return TRUE + if(F.do_not_edit && (alert("WARNING: This file is not compatible with editor. Editing it may result in permanently corrupted formatting or damaged data consistency. Edit anyway?", "Incompatible File", "No", "Yes") == "No")) + return TRUE + screen = new_screen + + if("PRG_open_file") . = TRUE var/obj/item/computer_hardware/hard_drive/HDD = computer.hard_drive - var/datum/computer_file/F = HDD.find_file_by_name(params["PRG_openfile"]) + var/datum/computer_file/F = HDD.find_file_by_name(params["PRG_open_file"]) if(!F) return if(F.can_access_file(usr)) - open_file = params["PRG_openfile"] + open_file = params["PRG_open_file"] open_file_is_usb = FALSE + screen = FMS_SHOWFILE - if("PRG_usbopenfile") + if("PRG_usb_open_file") . = TRUE var/obj/item/computer_hardware/hard_drive/RHDD = computer.portable_drive if(!RHDD) return - var/datum/computer_file/F = RHDD.find_file_by_name(params["PRG_usbopenfile"]) + var/datum/computer_file/F = RHDD.find_file_by_name(params["PRG_usb_open_file"]) if(!F) return if(F.can_access_file(usr)) - open_file = params["PRG_usbopenfile"] + open_file = params["PRG_usb_open_file"] open_file_is_usb = TRUE + screen = FMS_SHOWFILE - if("PRG_newtextfile") + if("PRG_new_text_file") . = TRUE - var/newname = sanitize_filename(tgui_input_text(usr, "Enter file name or leave blank to cancel:", "File rename")) - if(!newname) - return TRUE var/obj/item/computer_hardware/hard_drive/HDD = computer.hard_drive if(!HDD) return TRUE var/datum/computer_file/data/F = new/datum/computer_file/data() - F.filename = newname + F.filename = "NewFile" F.filetype = "TXT" + F.stored_data = "" if(!HDD.store_file(F)) qdel(F) error = "I/O ERROR: Unable to create file. The hard drive may be full, read-only, or contain a conflicting file." + open_file = F.filename + screen = FMS_EDIT - if("PRG_deletefile") + if("PRG_delete_file") . = TRUE var/obj/item/computer_hardware/hard_drive/HDD = computer.hard_drive if(!HDD) return TRUE - var/datum/computer_file/file = HDD.find_file_by_name(params["PRG_deletefile"]) + var/datum/computer_file/file = HDD.find_file_by_name(params["PRG_delete_file"]) if(!file || file.undeletable) return TRUE HDD.remove_file(file) - if("PRG_usbdeletefile") + if("PRG_usb_delete_file") . = TRUE var/obj/item/computer_hardware/hard_drive/RHDD = computer.portable_drive if(!RHDD) return TRUE - var/datum/computer_file/file = RHDD.find_file_by_name(params["PRG_usbdeletefile"]) + var/datum/computer_file/file = RHDD.find_file_by_name(params["PRG_usb_delete_file"]) if(!file || file.undeletable) return TRUE RHDD.remove_file(file) - if("PRG_closefile") + if("PRG_close_file") . = TRUE open_file = null open_file_is_usb = FALSE error = null + screen = FMS_FILEBROWSER if("PRG_clone") . = TRUE @@ -162,42 +218,29 @@ qdel(C) error = "I/O ERROR: Unable to clone file. The hard drive may be full, read-only, or contain a conflicting file." - if("PRG_rename") - . = TRUE - var/obj/item/computer_hardware/hard_drive/HDD = computer.hard_drive - if(!HDD) - return TRUE - var/datum/computer_file/file = HDD.find_file_by_name(params["PRG_rename"]) - if(!file || !istype(file)) - return TRUE - var/newname = sanitize_filename(tgui_input_text(usr, "Enter new file name or leave blank to cancel:", "File rename", file.filename)) - if(file && newname) - file.filename = newname - if("PRG_edit") . = TRUE - if(!open_file) - return TRUE var/obj/item/computer_hardware/hard_drive/HDD = computer.hard_drive if(!HDD) return TRUE var/datum/computer_file/data/F = HDD.find_file_by_name(open_file) if(!F || !istype(F)) return TRUE - if(F.do_not_edit && (alert("WARNING: This file is not compatible with editor. Editing it may result in permanently corrupted formatting or damaged data consistency. Edit anyway?", "Incompatible File", "No", "Yes") == "No")) - return TRUE - var/oldtext = html_decode(F.stored_data) - oldtext = replacetext(oldtext, "\[editorbr\]", "\n") - - var/newtext = sanitize_filename(replacetext(tgui_input_text(usr, "Editing file [open_file]. You may use most tags used in paper formatting:", "Text Editor", oldtext), "\n", "\[editorbr\]"), MAX_TEXTFILE_LENGTH) - if(!newtext) + if(params["PRG_rename"]) + var/newname = sanitize_filename(params["PRG_rename"]) + F.filename = newname + open_file = newname return - if(F) + if(params["PRG_desc"]) + F.filedesc = params["PRG_desc"] + return + + if(params["PRG_edit"]) var/datum/computer_file/data/backup = F.clone() HDD.remove_file(F) - F.stored_data = newtext + F.stored_data = params["PRG_edit"] F.calculate_size() // We can't store the updated file, it's probably too large. Print an error and restore backed up version. // This is mostly intended to prevent people from losing texts they spent lot of time working on due to running out of space. @@ -206,7 +249,7 @@ error = "I/O error: Unable to overwrite file. Hard drive is probably full. You may want to backup your changes before closing this window:

[html_decode(F.stored_data)]

" HDD.store_file(backup) - if("PRG_printfile") + if("PRG_print_file") . = TRUE if(!open_file) return TRUE @@ -221,29 +264,26 @@ if(!F) return TRUE if(istype(F)) - if(!computer.nano_printer.print_text(F.stored_data)) + if(!computer.nano_printer.print_text(F.stored_data, F.filename)) error = "Hardware error: Printer was unable to print the file. It may be out of paper." return FALSE else if(istype(S)) - if(!computer.nano_printer.print_text(S.code)) + if(!computer.nano_printer.print_text(S.code, S.filename)) error = "Hardware error: Printer was unable to print the file. It may be out of paper." return FALSE - if("PRG_copytousb") + if("PRG_copy_to_usb") . = TRUE var/obj/item/computer_hardware/hard_drive/HDD = computer.hard_drive var/obj/item/computer_hardware/hard_drive/portable/RHDD = computer.portable_drive if(!HDD || !RHDD) return FALSE - var/datum/computer_file/F = HDD.find_file_by_name(params["PRG_copytousb"]) + var/datum/computer_file/F = HDD.find_file_by_name(params["PRG_copy_to_usb"]) if(!F || !istype(F)) return FALSE var/is_usr_tech_support = FALSE - if(ishuman(usr)) - var/mob/living/carbon/human/H = usr - var/obj/item/card/id/ID = H.GetIdCard() - if(ACCESS_IT in ID.access) - is_usr_tech_support = TRUE + if(can_run(usr,FALSE,ACCESS_IT)) + is_usr_tech_support = TRUE if(!is_usr_tech_support && computer.enrolled != DEVICE_PRIVATE && istype(F, /datum/computer_file/program)) to_chat(usr, SPAN_WARNING("Work devices can't export programs to portable drives! Contact Tech Support to get them to load it.")) return TRUE @@ -258,21 +298,18 @@ return RHDD.store_file(C) - if("PRG_copyfromusb") + if("PRG_copy_from_usb") . = TRUE var/obj/item/computer_hardware/hard_drive/HDD = computer.hard_drive var/obj/item/computer_hardware/hard_drive/portable/RHDD = computer.portable_drive if(!HDD || !RHDD) return TRUE - var/datum/computer_file/F = RHDD.find_file_by_name(params["PRG_copyfromusb"]) + var/datum/computer_file/F = RHDD.find_file_by_name(params["PRG_copy_from_usb"]) if(!F || !istype(F)) return TRUE var/is_usr_tech_support = FALSE - if(ishuman(usr)) - var/mob/living/carbon/human/H = usr - var/obj/item/card/id/ID = H.GetIdCard() - if(ACCESS_IT in ID.access) - is_usr_tech_support = TRUE + if(can_run(usr,FALSE,ACCESS_IT)) + is_usr_tech_support = TRUE if(!is_usr_tech_support && computer.enrolled != DEVICE_PRIVATE && istype(F, /datum/computer_file/program)) to_chat(usr, SPAN_WARNING("Work devices can't import programs from portable drives! Contact Tech Support to get them to load it.")) return TRUE @@ -292,22 +329,72 @@ var/obj/item/computer_hardware/hard_drive/HDD = computer.hard_drive if (!HDD) return - var/datum/computer_file/F = HDD.find_file_by_name(params["PRG_encrypt"]) + var/datum/computer_file/F = HDD.find_file_by_name(params["PRG_file_to_encrypt"]) if(!F || F.undeletable) return - if(F.password) - return - F.password = sanitize_filename(tgui_input_text(usr, "Enter an encryption key:", "Encrypt File")) - - if("PRG_decrypt") - . = TRUE - var/obj/item/computer_hardware/hard_drive/HDD = computer.hard_drive - if (!HDD) - return - var/datum/computer_file/F = HDD.find_file_by_name(params["PRG_encrypt"]) - if(!F || F.undeletable) - return - if (F.can_access_file(usr)) + if(F.password && F.password == params["PRG_encrypt"]) F.password = "" + return + F.password = params["PRG_encrypt"] + + if("PRG_sort_forms") + sql_filter_dept = sanitize(params["department"]) + return TRUE + + if("PRG_reset_sql") + sql_filter_dept = "" + return TRUE + + if("PRG_generate_form") + var/obj/item/computer_hardware/hard_drive/HDD = computer.hard_drive + var/printid = text2num(params["id"]) + if(!printid) + return TRUE + if(!SSdbcore.Connect()) + to_chat(usr, SPAN_WARNING("Connection to the database lost. Aborting.")) + return TRUE + var/datum/db_query/query = SSdbcore.NewQuery( + "SELECT id, name, data FROM ss13_forms WHERE id = :id", + list("id" = printid)) + if(!query.Execute()) + to_chat(usr, SPAN_WARNING("Connection to the database lost. Aborting.")) + qdel(query) + return TRUE + while(query.NextRow()) + var/datum/computer_file/data/F = new/datum/computer_file/data() + F.filename = "SCCF-[query.item[1]]-[query.item[2]]" + F.filetype = "TXT" + F.stored_data = query.item[3] + if(!HDD.store_file(F)) + qdel(F) + error = "I/O ERROR: Unable to create file. The hard drive may be full, read-only, or contain a conflicting file." + qdel(query) + screen = FMS_FILEBROWSER + return TRUE + + if("PRG_whatis") + var/whatisid = text2num(params["id"]) + if(!whatisid) + return TRUE + if(!SSdbcore.Connect()) + to_chat(usr, SPAN_WARNING("Connection to the database lost. Aborting.")) + return TRUE + var/datum/db_query/query = SSdbcore.NewQuery( + "SELECT id, name, department, info FROM ss13_forms WHERE id = :id", + list("id" = whatisid)) + if(!query.Execute()) + to_chat(usr, SPAN_WARNING("Connection to the database lost. Aborting.")) + qdel(query) + return TRUE + var/dat = "
Stellar Corporate Conglomerate Form
" + while(query.NextRow()) + dat += "SCCF-[query.item[1]]

" + dat += "[query.item[2]]
" + dat += "[query.item[3]] Department
" + dat += "[query.item[4]]" + dat += "
" + qdel(query) + usr << browse(HTML_SKELETON(dat), "window=Information;size=560x240") + return TRUE #undef MAX_TEXTFILE_LENGTH diff --git a/html/changelogs/CatsinHD - FileManagerRefresh.yml b/html/changelogs/CatsinHD - FileManagerRefresh.yml new file mode 100644 index 00000000000..f6ae508845e --- /dev/null +++ b/html/changelogs/CatsinHD - FileManagerRefresh.yml @@ -0,0 +1,61 @@ +################################ +# Example Changelog File +# +# Note: This file, and files beginning with ".", and files that don't end in ".yml" will not be read. If you change this file, you will look really dumb. +# +# Your changelog will be merged with a master changelog. (New stuff added only, and only on the date entry for the day it was merged.) +# When it is, any changes listed below will disappear. +# +# Valid Prefixes: +# bugfix +# - (fixes bugs) +# wip +# - (work in progress) +# qol +# - (quality of life) +# soundadd +# - (adds a sound) +# sounddel +# - (removes a sound) +# rscadd +# - (adds a feature) +# rscdel +# - (removes a feature) +# imageadd +# - (adds an image or sprite) +# imagedel +# - (removes an image or sprite) +# spellcheck +# - (fixes spelling or grammar) +# experiment +# - (experimental change) +# balance +# - (balance changes) +# code_imp +# - (misc internal code change) +# refactor +# - (refactors code) +# config +# - (makes a change to the config files) +# admin +# - (makes changes to administrator tools) +# server +# - (miscellaneous changes to server) +################################# + +# Your name. +author: CatsinHD + +# Optional: Remove this file after generating master changelog. Useful for PR changelogs that won't get used again. +delete-after: True + +# Any changes you've made. See valid prefix list above. +# INDENT WITH TWO SPACES. NOT TABS. SPACES. +# SCREW THIS UP AND IT WON'T WORK. +# Also, this gets changed to [] after reading. Just remove the brackets when you add new shit. +# Please surround your changes in double quotes ("). It works without them, but if you use certain characters it screws up compiling. The quotes will not show up in the changelog. +changes: + - rscadd: "Added a new TGUI component for pencode markup and text editing." + - rscadd: "Added form database to File Manager to download digital forms." + - qol: "Shifts all editing, renaming, encrypting, etc. into File Manager UI." + - bugfix: "Fixes potential issue with ID checks for IT access in File Manager." diff --git a/tgui/packages/tgui/interfaces/FileManager.tsx b/tgui/packages/tgui/interfaces/FileManager.tsx index d9e5e2a22b8..d2b6e86fd10 100644 --- a/tgui/packages/tgui/interfaces/FileManager.tsx +++ b/tgui/packages/tgui/interfaces/FileManager.tsx @@ -1,72 +1,102 @@ -import { Box, Button, NoticeBox, Section, Table } from 'tgui-core/components'; +import { + Box, + Button, + Input, + NoticeBox, + Section, + Table, + Divider, +} from 'tgui-core/components'; import type { BooleanLike } from 'tgui-core/react'; import { useBackend } from '../backend'; import { NtosWindow } from '../layouts'; import { sanitizeText } from '../sanitize'; +import TextEditor from './common/TextEditor'; +import React, { useState } from 'react'; -export type FileData = { +// Screen constants (must match DM defines) +const FMS_FILEBROWSER = 0; +const FMS_SHOWFILE = 1; +const FMS_FORMS = 2; +const FMS_EDIT = 3; + +// Type Defs + +type FileData = { error: string; - usbconnected: BooleanLike; - scriptdata: string; - filedata: string; + sql_error?: BooleanLike; + usb_connected: BooleanLike; + script_data: string; + file_data: string; + file_desc: string; file_is_usb: BooleanLike; - filename: string; + file_name: string; files: File[]; - usbfiles: File[]; + forms?: FormEntry[]; + usb_files: File[]; + screen: number; }; type File = { name: string; type: string; size: number; + desc: string; undeletable: BooleanLike; password: BooleanLike; }; +type FormEntry = { + id: string; + name: string; + department: string; +}; + +// Program screen manager export const FileManager = (props) => { const { act, data } = useBackend(); + const {screen} = data; return ( - - - {data.error ? ( -
- {data.error} - - The program has encountered an unexpected error and cannot - continue. Please{' '} -
- ) : data.filename ? ( - - ) : ( - - )} -
-
+ + + {screen === FMS_FILEBROWSER && } + {screen === FMS_FORMS && } + {screen === FMS_SHOWFILE && } + {screen === FMS_EDIT && } + + ); }; -export const ShowFiles = (props) => { +export const FileBrowser = (props) => { const { act, data } = useBackend(); + const [encrypt, setEncrypting] = useState(false); + const [file_encrypt, setFileEncrypt] = useState(''); return (
act('PRG_newtextfile')} - /> - } > +
- ); + ) }; export const ShowFile = (props) => { const { act, data } = useBackend(); - const contentHtml = { __html: sanitizeText(data.filedata) }; + const contentHtml = { __html: sanitizeText(data.file_data) }; return (
); }; + +export const FormBrowser = (props) => { + const { act, data } = useBackend(); + +return ( +
act('set_screen', { screen: FMS_FILEBROWSER })} + /> + } + > + {data.sql_error ? ( + + Database connection failed or no forms found. + + ) : !data.forms ? ( + Loading... + ) : ( + <> + +
+ ); +}; + +export const File_Edit = (props) => { + const { act, data } = useBackend(); + + return ( + +
act('PRG_edit', { PRG_rename: e })} + /> } + buttons={ + <> +
+ ); +}; diff --git a/tgui/packages/tgui/interfaces/common/TextEditor.tsx b/tgui/packages/tgui/interfaces/common/TextEditor.tsx new file mode 100644 index 00000000000..a815d594aa7 --- /dev/null +++ b/tgui/packages/tgui/interfaces/common/TextEditor.tsx @@ -0,0 +1,114 @@ +import { + Box, + Button, + TextArea +} from 'tgui-core/components'; +import React, { useState, useRef } from 'react'; + +interface TextEditorProps { + placeholder?: string; + initial_text?: string; + onChange?: (value: string) => void; +} + +/** + * Textarea and toolbar with integrated pencode markup. + * + * Expects placeholder text and initial text value. + * Exports onChange, providing the new text as a string. + * + */ +export default function TextEditor({ + placeholder = "Type something here...", + initial_text = "", + onChange +}: TextEditorProps) { + const [text, setText] = useState(initial_text); + const textareaRef = useRef(null); + + const applyFormatting = (prefix: string, suffix: string) => { + const textarea = textareaRef.current; + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + + const selected_text = text.substring(start, end); + const replacement = `${prefix}${selected_text}${suffix}`; + const new_text = text.substring(0, start) + replacement + text.substring(end); + + setText(new_text); + if (onChange) onChange(new_text); + + setTimeout(() => { + textarea.focus(); + }, 0); + }; + + return ( + +