Skip to content

Commit

Permalink
Improvements in the text APIs
Browse files Browse the repository at this point in the history
  • Loading branch information
andrerpena committed May 2, 2022
1 parent 7fa3ba2 commit 9fbceff
Show file tree
Hide file tree
Showing 18 changed files with 179 additions and 253 deletions.
39 changes: 18 additions & 21 deletions demo/index.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,48 @@
import * as React from "react";
import ReactDOM from "react-dom";
import {
Box,
Button,
ChakraProvider,
HStack,
Textarea
} from "@chakra-ui/react";
import { Box, ChakraProvider, HStack, Textarea } from "@chakra-ui/react";
import { useTextAreaMarkdownEditor } from "../src/hooks/use-markdown-editor";
import { faBold, faItalic } from "@fortawesome/free-solid-svg-icons";
import { faBold, faItalic, faCode } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { bold, italic } from "../src";
import { bold, code, italic } from "../src";
import { ToolbarButton } from "./toolbar-button";

export type DemoProps = {};

export const Demo: React.FunctionComponent<DemoProps> = props => {
export const Demo: React.FunctionComponent<DemoProps> = () => {
const { ref, commandController } = useTextAreaMarkdownEditor({
commandMap: {
bold: bold,
italic: italic
italic: italic,
code: code
}
});

return (
<ChakraProvider>
<Box p={3}>
<HStack py={2}>
<Button
variant={"outline"}
size={"sm"}
color={"gray.600"}
<ToolbarButton
onClick={async () => {
await commandController.executeCommand("bold");
}}
>
<FontAwesomeIcon icon={faBold} />
</Button>
<Button
variant={"outline"}
size={"sm"}
color={"gray.600"}
</ToolbarButton>
<ToolbarButton
onClick={async () => {
await commandController.executeCommand("italic");
}}
>
<FontAwesomeIcon icon={faItalic} />
</Button>
</ToolbarButton>
<ToolbarButton
onClick={async () => {
await commandController.executeCommand("code");
}}
>
<FontAwesomeIcon icon={faCode} />
</ToolbarButton>
</HStack>
<Textarea
ref={ref}
Expand Down
32 changes: 17 additions & 15 deletions src/commands/command-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,28 +34,30 @@ export class CommandController {
): Promise<void> {
if (this.isExecuting) {
// The simplest thing to do is to ignore commands while
// there is already a command executing. The alternative would be to queue commands
// there is already a command execu
// ting. The alternative would be to queue commands
// but there is no guarantee that the state after one command executes will still be compatible
// with the next one. In fact, it is likely not to be.
return;
}

this.isExecuting = true;
const command = this.commandMap[commandName];
const result = command.execute({

if (!command) {
throw new Error(
`Cannot execute command. Command not found: ${commandName}`
);
}

const executeOptions = {
initialState: this.textController.getState(),
textApi: this.textController,
context
});
await result;
this.isExecuting = false;
}
textApi: this.textController
};

/**
* Returns a command by name
* @param name
*/
getCommandByName(name: string) {
return this.commandMap[name];
if (command.shouldUndo?.(executeOptions) && command?.undo) {
command.undo(executeOptions);
} else {
await command.execute(executeOptions);
}
}
}
10 changes: 7 additions & 3 deletions src/commands/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,19 @@ import { TextController, TextState } from "../types/CommandOptions";
export interface ExecuteOptions {
initialState: TextState;
textApi: TextController;
context?: CommandContext;
}

export interface Command {
execute: (options: ExecuteOptions) => void | Promise<void>;
shouldUndo?: (options: Pick<ExecuteOptions, "initialState">) => boolean;
execute: (options: ExecuteOptions) => void;
undo?: (options: ExecuteOptions) => void;
}

export interface CommandContext {
type: string;
}

export type CommandMap = Record<string, Command>;
export type CommandMap<CommandName extends string> = Record<
CommandName,
Command
>;
31 changes: 26 additions & 5 deletions src/commands/markdown-commands/bold.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,42 @@
import * as React from "react";
import { Command } from "../command";
import { markdownHelpers } from "../../helpers/markdown-helpers";

import { textHelpers } from "../../helpers/textHelpers";
export const bold: Command = {
shouldUndo: options => {
return (
textHelpers.getCharactersBeforeSelection(options.initialState, 2) ===
"**" &&
textHelpers.getCharactersAfterSelection(options.initialState, 2) === "**"
);
},
execute: ({ initialState, textApi }) => {
// Adjust the selection to encompass the whole word if the caret is inside one
const newSelectionRange = markdownHelpers.selectWord({
const newSelectionRange = textHelpers.selectWord({
text: initialState.text,
selection: initialState.selection
});
const state1 = textApi.setSelectionRange(newSelectionRange);
// Replaces the current selection with the bold mark up
const state2 = textApi.replaceSelection(`**${state1.selectedText}**`);
const state2 = textApi.replaceSelection(
`**${textHelpers.getSelectedText(state1)}**`
);
// Adjust the selection to not contain the **
textApi.setSelectionRange({
start: state2.selection.end - 2 - state1.selectedText.length,
start:
state2.selection.end - 2 - textHelpers.getSelectedText(state1).length,
end: state2.selection.end - 2
});
},
undo: ({ initialState, textApi }) => {
const text = textHelpers.getSelectedText(initialState);
textApi.setSelectionRange({
start: initialState.selection.start - 2,
end: initialState.selection.end + 2
});
textApi.replaceSelection(text);
textApi.setSelectionRange({
start: initialState.selection.start - 2,
end: initialState.selection.end - 2
});
}
};
68 changes: 29 additions & 39 deletions src/commands/markdown-commands/code.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,43 @@
import * as React from "react";
import { Command } from "../command";
import { markdownHelpers } from "../../helpers/markdown-helpers";
import { textHelpers } from "../../helpers/textHelpers";

export const code: Command = {
execute: async ({ textApi, initialState }) => {
shouldUndo: options => {
return (
textHelpers.getCharactersBeforeSelection(options.initialState, 1) ===
"`" &&
textHelpers.getCharactersAfterSelection(options.initialState, 1) === "`"
);
},
execute: ({ initialState, textApi }) => {
// Adjust the selection to encompass the whole word if the caret is inside one
const newSelectionRange = markdownHelpers.selectWord({
const newSelectionRange = textHelpers.selectWord({
text: initialState.text,
selection: initialState.selection
});
const state1 = textApi.setSelectionRange(newSelectionRange);

// when there's no breaking line
if (state1.selectedText.indexOf("\n") === -1) {
textApi.replaceSelection(`\`${state1.selectedText}\``);
// Adjust the selection to not contain the **

const selectionStart = state1.selection.start + 1;
const selectionEnd = selectionStart + state1.selectedText.length;

textApi.setSelectionRange({
start: selectionStart,
end: selectionEnd
});
return;
}

const breaksBeforeCount = markdownHelpers.getBreaksNeededForEmptyLineBefore(
state1.text,
state1.selection.start
);
const breaksBefore = Array(breaksBeforeCount + 1).join("\n");

const breaksAfterCount = markdownHelpers.getBreaksNeededForEmptyLineAfter(
state1.text,
state1.selection.end
// Replaces the current selection with the italic mark up
const state2 = textApi.replaceSelection(
`\`${textHelpers.getSelectedText(state1)}\``
);
const breaksAfter = Array(breaksAfterCount + 1).join("\n");

textApi.replaceSelection(
`${breaksBefore}\`\`\`\n${state1.selectedText}\n\`\`\`${breaksAfter}`
);

const selectionStart = state1.selection.start + breaksBeforeCount + 4;
const selectionEnd = selectionStart + state1.selectedText.length;

// Adjust the selection to not contain the *
textApi.setSelectionRange({
start:
state2.selection.end - 1 - textHelpers.getSelectedText(state1).length,
end: state2.selection.end - 1
});
},
undo: ({ initialState, textApi }) => {
const text = textHelpers.getSelectedText(initialState);
textApi.setSelectionRange({
start: initialState.selection.start - 1,
end: initialState.selection.end + 1
});
textApi.replaceSelection(text);
textApi.setSelectionRange({
start: selectionStart,
end: selectionEnd
start: initialState.selection.start - 1,
end: initialState.selection.end - 1
});
}
};
10 changes: 6 additions & 4 deletions src/commands/markdown-commands/header.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as React from "react";
import { Command } from "../command";
import { markdownHelpers } from "../../helpers/markdown-helpers";
import { textHelpers } from "../../helpers/textHelpers";
import { TextController, TextState } from "../../types/CommandOptions";

function setHeader(
Expand All @@ -9,16 +9,18 @@ function setHeader(
prefix: string
) {
// Adjust the selection to encompass the whole word if the caret is inside one
const newSelectionRange = markdownHelpers.selectWord({
const newSelectionRange = textHelpers.selectWord({
text: initialState.text,
selection: initialState.selection
});
const state1 = api.setSelectionRange(newSelectionRange);
// Add the prefix to the selection
const state2 = api.replaceSelection(`${prefix}${state1.selectedText}`);
const state2 = api.replaceSelection(
`${prefix}${textHelpers.getSelectedText(state1)}`
);
// Adjust the selection to not contain the prefix
api.setSelectionRange({
start: state2.selection.end - state1.selectedText.length,
start: state2.selection.end - textHelpers.getSelectedText(state1).length,
end: state2.selection.end
});
}
Expand Down
7 changes: 4 additions & 3 deletions src/commands/markdown-commands/image.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
import * as React from "react";
import { Command } from "../command";
import { markdownHelpers } from "../../helpers/markdown-helpers";
import { textHelpers } from "../../helpers/textHelpers";

export const image: Command = {
execute: ({ initialState, textApi }) => {
// Replaces the current selection with the whole word selected
const state1 = textApi.setSelectionRange(
markdownHelpers.selectWord({
textHelpers.selectWord({
text: initialState.text,
selection: initialState.selection
})
);
// Replaces the current selection with the image
const imageTemplate =
state1.selectedText || "https://example.com/your-image.png";
textHelpers.getSelectedText(state1) ||
"https://example.com/your-image.png";
textApi.replaceSelection(`![](${imageTemplate})`);
// Adjust the selection to not contain the **
textApi.setSelectionRange({
Expand Down
30 changes: 26 additions & 4 deletions src/commands/markdown-commands/italic.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,43 @@
import * as React from "react";
import { Command } from "../command";
import { markdownHelpers } from "../../helpers/markdown-helpers";
import { textHelpers } from "../../helpers/textHelpers";

export const italic: Command = {
shouldUndo: options => {
return (
textHelpers.getCharactersBeforeSelection(options.initialState, 1) ===
"*" &&
textHelpers.getCharactersAfterSelection(options.initialState, 1) === "*"
);
},
execute: ({ initialState, textApi }) => {
// Adjust the selection to encompass the whole word if the caret is inside one
const newSelectionRange = markdownHelpers.selectWord({
const newSelectionRange = textHelpers.selectWord({
text: initialState.text,
selection: initialState.selection
});
const state1 = textApi.setSelectionRange(newSelectionRange);
// Replaces the current selection with the italic mark up
const state2 = textApi.replaceSelection(`*${state1.selectedText}*`);
const state2 = textApi.replaceSelection(
`*${textHelpers.getSelectedText(state1)}*`
);
// Adjust the selection to not contain the *
textApi.setSelectionRange({
start: state2.selection.end - 1 - state1.selectedText.length,
start:
state2.selection.end - 1 - textHelpers.getSelectedText(state1).length,
end: state2.selection.end - 1
});
},
undo: ({ initialState, textApi }) => {
const text = textHelpers.getSelectedText(initialState);
textApi.setSelectionRange({
start: initialState.selection.start - 1,
end: initialState.selection.end + 1
});
textApi.replaceSelection(text);
textApi.setSelectionRange({
start: initialState.selection.start - 1,
end: initialState.selection.end - 1
});
}
};
11 changes: 7 additions & 4 deletions src/commands/markdown-commands/link.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
import * as React from "react";
import { Command } from "../command";
import { markdownHelpers } from "../../helpers/markdown-helpers";
import { textHelpers } from "../../helpers/textHelpers";

export const link: Command = {
execute: ({ initialState, textApi }) => {
// Adjust the selection to encompass the whole word if the caret is inside one
const newSelectionRange = markdownHelpers.selectWord({
const newSelectionRange = textHelpers.selectWord({
text: initialState.text,
selection: initialState.selection
});
const state1 = textApi.setSelectionRange(newSelectionRange);
// Replaces the current selection with the bold mark up
const state2 = textApi.replaceSelection(`[${state1.selectedText}](url)`);
const state2 = textApi.replaceSelection(
`[${textHelpers.getSelectedText(state1)}](url)`
);
// Adjust the selection to not contain the **
textApi.setSelectionRange({
start: state2.selection.end - 6 - state1.selectedText.length,
start:
state2.selection.end - 6 - textHelpers.getSelectedText(state1).length,
end: state2.selection.end - 6
});
}
Expand Down
Loading

0 comments on commit 9fbceff

Please sign in to comment.