Merge pull request #1242 from hgiesel/codable

In-line HTML-Editing for Editor
This commit is contained in:
Damien Elmes 2021-06-18 17:18:35 +10:00 committed by GitHub
commit 039be57499
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
28 changed files with 645 additions and 372 deletions

View file

@ -3,9 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import type { Readable } from "svelte/store";
import { getContext, onMount, createEventDispatcher } from "svelte";
import { disabledKey, nightModeKey, dropdownKey } from "./contextKeys";
import { nightModeKey, dropdownKey } from "./contextKeys";
import type { DropdownProps } from "./dropdown";
export let id: string | undefined = undefined;
@ -14,7 +13,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let tooltip: string | undefined = undefined;
export let active = false;
export let disables = true;
export let disabled = false;
export const disables = false; /* unused */
export let tabbable = false;
export let iconSize: number = 75;
@ -22,9 +22,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let buttonRef: HTMLButtonElement;
const disabled = getContext<Readable<boolean>>(disabledKey);
$: _disabled = disables && $disabled;
const nightMode = getContext<boolean>(nightModeKey);
const dropdownProps = getContext<DropdownProps>(dropdownKey) ?? { dropdown: false };
@ -43,7 +40,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
style={`--icon-size: ${iconSize}%`}
title={tooltip}
{...dropdownProps}
disabled={_disabled}
{disabled}
tabindex={tabbable ? 0 : -1}
on:click
on:mousedown|preventDefault

View file

@ -0,0 +1,16 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import type { Readable } from "svelte/store";
import { getContext } from "svelte";
type T = boolean;
export let key: Symbol | string;
const store = getContext<Readable<T>>(key);
</script>
<slot context={$store} />

View file

@ -5,14 +5,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="typescript" context="module">
import { writable } from "svelte/store";
type UpdaterMap = Map<string, (event: Event) => boolean>;
type StateMap = Map<string, boolean>;
type KeyType = Symbol | string;
type UpdaterMap = Map<KeyType, (event: Event) => boolean>;
type StateMap = Map<KeyType, boolean>;
const updaterMap = new Map() as UpdaterMap;
const stateMap = new Map() as StateMap;
const stateStore = writable(stateMap);
function updateAllStateWithCallback(callback: (key: string) => boolean): void {
function updateAllStateWithCallback(callback: (key: KeyType) => boolean): void {
stateStore.update((map: StateMap): StateMap => {
const newMap = new Map() as StateMap;
@ -25,7 +26,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
export function updateAllState(event: Event): void {
updateAllStateWithCallback((key: string): boolean =>
updateAllStateWithCallback((key: KeyType): boolean =>
updaterMap.get(key)!(event)
);
}
@ -34,7 +35,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
updateAllStateWithCallback((): boolean => state);
}
function updateStateByKey(key: string, event: Event): void {
function updateStateByKey(key: KeyType, event: Event): void {
stateStore.update((map: StateMap): StateMap => {
map.set(key, updaterMap.get(key)!(event));
return map;
@ -43,7 +44,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script>
<script lang="typescript">
export let key: string;
export let key: KeyType;
export let update: (event: Event) => boolean;
let state: boolean = false;

View file

@ -73,6 +73,8 @@ ts_library(
"//ts/html-filter",
"//ts:image_module_support",
"@npm//svelte",
"@npm//@types/codemirror",
"@npm//codemirror",
] + svelte_names,
)
@ -177,7 +179,7 @@ svelte_check(
]) + [
"//ts/sass:button_mixins_lib",
"//ts/sass/bootstrap",
"//ts/components:svelte_components",
"@npm//@types/bootstrap",
"//ts/components",
],
)

View file

@ -4,20 +4,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import * as tr from "lib/i18n";
import { disabledKey } from "components/contextKeys";
import IconButton from "components/IconButton.svelte";
import WithShortcut from "components/WithShortcut.svelte";
import WithContext from "components/WithContext.svelte";
import { bracketsIcon } from "./icons";
import { forEditorField } from ".";
import { wrap } from "./wrap";
import { wrapCurrent } from "./wrap";
const clozePattern = /\{\{c(\d+)::/gu;
function getCurrentHighestCloze(increment: boolean): number {
let highest = 0;
forEditorField([], (field) => {
const fieldHTML = field.editingArea.editable.fieldHTML;
const fieldHTML = field.editingArea.fieldHTML;
const matches: number[] = [];
let match: RegExpMatchArray | null = null;
@ -37,16 +39,19 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
function onCloze(event: KeyboardEvent | MouseEvent): void {
const highestCloze = getCurrentHighestCloze(!event.getModifierState("Alt"));
wrap(`{{c${highestCloze}::`, "}}");
wrapCurrent(`{{c${highestCloze}::`, "}}");
}
</script>
<WithShortcut shortcut={"Control+Alt?+Shift+C"} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={`${tr.editingClozeDeletion()} (${shortcutLabel})`}
on:click={onCloze}
on:mount={createShortcut}
>
{@html bracketsIcon}
</IconButton>
<WithContext key={disabledKey} let:context={disabled}>
<IconButton
tooltip={`${tr.editingClozeDeletion()} (${shortcutLabel})`}
{disabled}
on:click={onCloze}
on:mount={createShortcut}
>
{@html bracketsIcon}
</IconButton>
</WithContext>
</WithShortcut>

View file

@ -11,6 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ColorPicker from "components/ColorPicker.svelte";
import WithShortcut from "components/WithShortcut.svelte";
import WithColorHelper from "./WithColorHelper.svelte";
import OnlyEditable from "./OnlyEditable.svelte";
import { textColorIcon, highlightColorIcon, arrowIcon } from "./icons";
import { appendInParentheses } from "./helpers";
@ -28,51 +29,61 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroup {api}>
<WithColorHelper let:colorHelperIcon let:color let:setColor>
<ButtonGroupItem>
<WithShortcut shortcut={"F7"} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(
tr.editingSetForegroundColor(),
shortcutLabel
)}
on:click={wrapWithForecolor(color)}
on:mount={createShortcut}
>
{@html textColorIcon}
{@html colorHelperIcon}
</IconButton>
</WithShortcut>
</ButtonGroupItem>
<OnlyEditable let:disabled>
<ButtonGroupItem>
<WithShortcut shortcut={"F7"} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(
tr.editingSetForegroundColor(),
shortcutLabel
)}
{disabled}
on:click={wrapWithForecolor(color)}
on:mount={createShortcut}
>
{@html textColorIcon}
{@html colorHelperIcon}
</IconButton>
</WithShortcut>
</ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut={"F8"} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(
tr.editingChangeColor(),
shortcutLabel
)}
widthMultiplier={0.5}
>
{@html arrowIcon}
<ColorPicker on:change={setColor} on:mount={createShortcut} />
</IconButton>
</WithShortcut>
</ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut={"F8"} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(
tr.editingChangeColor(),
shortcutLabel
)}
{disabled}
widthMultiplier={0.5}
>
{@html arrowIcon}
<ColorPicker on:change={setColor} on:mount={createShortcut} />
</IconButton>
</WithShortcut>
</ButtonGroupItem>
</OnlyEditable>
</WithColorHelper>
<WithColorHelper let:colorHelperIcon let:color let:setColor>
<ButtonGroupItem>
<IconButton on:click={wrapWithBackcolor(color)}>
{@html highlightColorIcon}
{@html colorHelperIcon}
</IconButton>
</ButtonGroupItem>
<OnlyEditable let:disabled>
<ButtonGroupItem>
<IconButton on:click={wrapWithBackcolor(color)} {disabled}>
{@html highlightColorIcon}
{@html colorHelperIcon}
</IconButton>
</ButtonGroupItem>
<ButtonGroupItem>
<IconButton tooltip={tr.editingChangeColor()} widthMultiplier={0.5}>
{@html arrowIcon}
<ColorPicker on:change={setColor} />
</IconButton>
</ButtonGroupItem>
<ButtonGroupItem>
<IconButton
tooltip={tr.editingChangeColor()}
widthMultiplier={0.5}
{disabled}
>
{@html arrowIcon}
<ColorPicker on:change={setColor} />
</IconButton>
</ButtonGroupItem>
</OnlyEditable>
</WithColorHelper>
</ButtonGroup>

View file

@ -0,0 +1,79 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import IconButton from "components/IconButton.svelte";
import WithShortcut from "components/WithShortcut.svelte";
import WithState from "components/WithState.svelte";
import OnlyEditable from "./OnlyEditable.svelte";
import { appendInParentheses } from "./helpers";
export let key: string;
export let tooltip: string;
export let shortcut: string = "";
export let withoutShortcut = false;
export let withoutState = false;
</script>
<OnlyEditable let:disabled>
{#if withoutShortcut && withoutState}
<IconButton {tooltip} {disabled} on:click={() => document.execCommand(key)}>
<slot />
</IconButton>
{:else if withoutShortcut}
<WithState
{key}
update={() => document.queryCommandState(key)}
let:state={active}
let:updateState
>
<IconButton
{tooltip}
{active}
{disabled}
on:click={(event) => {
document.execCommand(key);
updateState(event);
}}
>
<slot />
</IconButton>
</WithState>
{:else if withoutState}
<WithShortcut {shortcut} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(tooltip, shortcutLabel)}
{disabled}
on:click={() => document.execCommand(key)}
on:mount={createShortcut}
>
<slot />
</IconButton>
</WithShortcut>
{:else}
<WithShortcut {shortcut} let:createShortcut let:shortcutLabel>
<WithState
{key}
update={() => document.queryCommandState(key)}
let:state={active}
let:updateState
>
<IconButton
tooltip={appendInParentheses(tooltip, shortcutLabel)}
{active}
{disabled}
on:click={(event) => {
document.execCommand(key);
updateState(event);
}}
on:mount={createShortcut}
>
<slot />
</IconButton>
</WithState>
</WithShortcut>
{/if}
</OnlyEditable>

View file

@ -11,8 +11,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import IconButton from "components/IconButton.svelte";
import ButtonDropdown from "components/ButtonDropdown.svelte";
import ButtonToolbarItem from "components/ButtonToolbarItem.svelte";
import WithState from "components/WithState.svelte";
import WithDropdownMenu from "components/WithDropdownMenu.svelte";
import OnlyEditable from "./OnlyEditable.svelte";
import CommandIconButton from "./CommandIconButton.svelte";
import { getListItem } from "./helpers";
import {
@ -46,134 +47,66 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroup {api}>
<ButtonGroupItem>
<WithState
<CommandIconButton
key="insertUnorderedList"
update={() => document.queryCommandState("insertUnorderedList")}
let:state={active}
let:updateState
tooltip={tr.editingUnorderedList()}
withoutShortcut>{@html ulIcon}</CommandIconButton
>
<IconButton
tooltip={tr.editingUnorderedList()}
{active}
on:click={(event) => {
document.execCommand("insertUnorderedList");
updateState(event);
}}
>
{@html ulIcon}
</IconButton>
</WithState>
</ButtonGroupItem>
<ButtonGroupItem>
<WithState
<CommandIconButton
key="insertOrderedList"
update={() => document.queryCommandState("insertOrderedList")}
let:state={active}
let:updateState
tooltip={tr.editingOrderedList()}
withoutShortcut>{@html olIcon}</CommandIconButton
>
<IconButton
tooltip={tr.editingOrderedList()}
{active}
on:click={(event) => {
document.execCommand("insertOrderedList");
updateState(event);
}}
>
{@html olIcon}
</IconButton>
</WithState>
</ButtonGroupItem>
<ButtonGroupItem>
<WithDropdownMenu let:createDropdown let:menuId>
<IconButton on:mount={createDropdown}>
{@html listOptionsIcon}
</IconButton>
<OnlyEditable let:disabled>
<IconButton {disabled} on:mount={createDropdown}>
{@html listOptionsIcon}
</IconButton>
</OnlyEditable>
<ButtonDropdown id={menuId}>
<ButtonToolbarItem id="justify">
<ButtonGroup>
<ButtonGroupItem>
<WithState
<CommandIconButton
key="justifyLeft"
update={() => document.queryCommandState("justifyLeft")}
let:state={active}
let:updateState
tooltip={tr.editingAlignLeft()}
withoutShortcut
>{@html justifyLeftIcon}</CommandIconButton
>
<IconButton
tooltip={tr.editingAlignLeft()}
{active}
on:click={(event) => {
document.execCommand("justifyLeft");
updateState(event);
}}
>
{@html justifyLeftIcon}
</IconButton>
</WithState>
</ButtonGroupItem>
<ButtonGroupItem>
<WithState
<CommandIconButton
key="justifyCenter"
update={() =>
document.queryCommandState("justifyCenter")}
let:state={active}
let:updateState
tooltip={tr.editingCenter()}
withoutShortcut
>{@html justifyCenterIcon}</CommandIconButton
>
<IconButton
tooltip={tr.editingCenter()}
{active}
on:click={(event) => {
document.execCommand("justifyCenter");
updateState(event);
}}
>
{@html justifyCenterIcon}
</IconButton>
</WithState>
</ButtonGroupItem>
<ButtonGroupItem>
<WithState
<CommandIconButton
key="justifyRight"
update={() =>
document.queryCommandState("justifyRight")}
let:state={active}
let:updateState
tooltip={tr.editingAlignRight()}
withoutShortcut
>{@html justifyRightIcon}</CommandIconButton
>
<IconButton
tooltip={tr.editingAlignRight()}
{active}
on:click={(event) => {
document.execCommand("justifyRight");
updateState(event);
}}
>
{@html justifyRightIcon}
</IconButton>
</WithState>
</ButtonGroupItem>
<ButtonGroupItem>
<WithState
<CommandIconButton
key="justifyFull"
update={() => document.queryCommandState("justifyFull")}
let:state={active}
let:updateState
tooltip={tr.editingJustify()}
withoutShortcut
>{@html justifyFullIcon}</CommandIconButton
>
<IconButton
tooltip={tr.editingJustify()}
{active}
on:click={(event) => {
document.execCommand("justifyFull");
updateState(event);
}}
>
{@html justifyFullIcon}
</IconButton>
</WithState>
</ButtonGroupItem>
</ButtonGroup>
</ButtonToolbarItem>
@ -181,21 +114,27 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonToolbarItem id="indentation">
<ButtonGroup>
<ButtonGroupItem>
<IconButton
on:click={outdentListItem}
tooltip={tr.editingOutdent()}
>
{@html outdentIcon}
</IconButton>
<OnlyEditable let:disabled>
<IconButton
on:click={outdentListItem}
tooltip={tr.editingOutdent()}
{disabled}
>
{@html outdentIcon}
</IconButton>
</OnlyEditable>
</ButtonGroupItem>
<ButtonGroupItem>
<IconButton
on:click={indentListItem}
tooltip={tr.editingIndent()}
>
{@html indentIcon}
</IconButton>
<OnlyEditable let:disabled>
<IconButton
on:click={indentListItem}
tooltip={tr.editingIndent()}
{disabled}
>
{@html indentIcon}
</IconButton>
</OnlyEditable>
</ButtonGroupItem>
</ButtonGroup>
</ButtonToolbarItem>

View file

@ -7,9 +7,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ButtonGroup from "components/ButtonGroup.svelte";
import ButtonGroupItem from "components/ButtonGroupItem.svelte";
import IconButton from "components/IconButton.svelte";
import WithState from "components/WithState.svelte";
import WithShortcut from "components/WithShortcut.svelte";
import CommandIconButton from "./CommandIconButton.svelte";
import {
boldIcon,
@ -19,147 +17,57 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
subscriptIcon,
eraserIcon,
} from "./icons";
import { appendInParentheses } from "./helpers";
export let api = {};
</script>
<ButtonGroup {api}>
<ButtonGroupItem>
<WithShortcut shortcut={"Control+B"} let:createShortcut let:shortcutLabel>
<WithState
key="bold"
update={() => document.queryCommandState("bold")}
let:state={active}
let:updateState
>
<IconButton
tooltip={appendInParentheses(tr.editingBoldText(), shortcutLabel)}
{active}
on:click={(event) => {
document.execCommand("bold");
updateState(event);
}}
on:mount={createShortcut}
>
{@html boldIcon}
</IconButton>
</WithState>
</WithShortcut>
<CommandIconButton
key="bold"
shortcut={"Control+B"}
tooltip={tr.editingBoldText()}>{@html boldIcon}</CommandIconButton
>
</ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut={"Control+I"} let:createShortcut let:shortcutLabel>
<WithState
key="italic"
update={() => document.queryCommandState("italic")}
let:state={active}
let:updateState
>
<IconButton
tooltip={appendInParentheses(tr.editingItalicText(), shortcutLabel)}
{active}
on:click={(event) => {
document.execCommand("italic");
updateState(event);
}}
on:mount={createShortcut}
>
{@html italicIcon}
</IconButton>
</WithState>
</WithShortcut>
<CommandIconButton
key="italic"
shortcut={"Control+I"}
tooltip={tr.editingItalicText()}>{@html italicIcon}</CommandIconButton
>
</ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut={"Control+U"} let:createShortcut let:shortcutLabel>
<WithState
key="underline"
update={() => document.queryCommandState("underline")}
let:state={active}
let:updateState
>
<IconButton
tooltip={appendInParentheses(
tr.editingUnderlineText(),
shortcutLabel
)}
{active}
on:click={(event) => {
document.execCommand("underline");
updateState(event);
}}
on:mount={createShortcut}
>
{@html underlineIcon}
</IconButton>
</WithState>
</WithShortcut>
<CommandIconButton
key="underline"
shortcut={"Control+U"}
tooltip={tr.editingUnderlineText()}>{@html underlineIcon}</CommandIconButton
>
</ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut={"Control+="} let:createShortcut let:shortcutLabel>
<WithState
key="superscript"
update={() => document.queryCommandState("superscript")}
let:state={active}
let:updateState
>
<IconButton
tooltip={appendInParentheses(
tr.editingSuperscript(),
shortcutLabel
)}
{active}
on:click={(event) => {
document.execCommand("superscript");
updateState(event);
}}
on:mount={createShortcut}
>
{@html superscriptIcon}
</IconButton>
</WithState>
</WithShortcut>
<CommandIconButton
key="superscript"
shortcut={"Control+="}
tooltip={tr.editingSuperscript()}>{@html superscriptIcon}</CommandIconButton
>
</ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut={"Control+Shift+="} let:createShortcut let:shortcutLabel>
<WithState
key="subscript"
update={() => document.queryCommandState("subscript")}
let:state={active}
let:updateState
>
<IconButton
tooltip={appendInParentheses(tr.editingSubscript(), shortcutLabel)}
{active}
on:click={(event) => {
document.execCommand("subscript");
updateState(event);
}}
on:mount={createShortcut}
>
{@html subscriptIcon}
</IconButton>
</WithState>
</WithShortcut>
<CommandIconButton
key="subscript"
shortcut={"Control+Shift+="}
tooltip={tr.editingSubscript()}>{@html subscriptIcon}</CommandIconButton
>
</ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut={"Control+R"} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(
tr.editingRemoveFormatting(),
shortcutLabel
)}
on:click={() => {
document.execCommand("removeFormat");
}}
on:mount={createShortcut}
>
{@html eraserIcon}
</IconButton>
</WithShortcut>
<CommandIconButton
key="removeFormat"
shortcut={"Control+R"}
tooltip={tr.editingRemoveFormatting()}
withoutState>{@html eraserIcon}</CommandIconButton
>
</ButtonGroupItem>
</ButtonGroup>

View file

@ -0,0 +1,15 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import WithContext from "components/WithContext.svelte";
import { disabledKey } from "components/contextKeys";
import { inCodableKey } from "./contextKeys";
</script>
<WithContext key={disabledKey} let:context={disabled}>
<WithContext key={inCodableKey} let:context={inCodable}>
<slot disabled={disabled || inCodable} />
</WithContext>
</WithContext>

View file

@ -5,6 +5,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="typescript">
import * as tr from "lib/i18n";
import { bridgeCommand } from "lib/bridgecommand";
import { disabledKey } from "components/contextKeys";
import { inCodableKey } from "./contextKeys";
import ButtonGroup from "components/ButtonGroup.svelte";
import ButtonGroupItem from "components/ButtonGroupItem.svelte";
@ -13,10 +15,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import DropdownItem from "components/DropdownItem.svelte";
import WithDropdownMenu from "components/WithDropdownMenu.svelte";
import WithShortcut from "components/WithShortcut.svelte";
import WithContext from "components/WithContext.svelte";
import OnlyEditable from "./OnlyEditable.svelte";
import ClozeButton from "./ClozeButton.svelte";
import { wrap } from "./wrap";
import { getCurrentField } from ".";
import { appendInParentheses } from "./helpers";
import { wrapCurrent } from "./wrap";
import { paperclipIcon, micIcon, functionIcon, xmlIcon } from "./icons";
export let api = {};
@ -29,38 +34,50 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
bridgeCommand("record");
}
function onHtmlEdit(): void {
bridgeCommand("htmlEdit");
function onHtmlEdit() {
const currentField = getCurrentField();
if (currentField) {
currentField.toggleHtmlEdit();
}
}
</script>
<ButtonGroup {api}>
<ButtonGroupItem>
<WithShortcut shortcut={"F3"} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(
tr.editingAttachPicturesaudiovideo(),
shortcutLabel
)}
iconSize={70}
on:click={onAttachment}
on:mount={createShortcut}
>
{@html paperclipIcon}
</IconButton>
<OnlyEditable let:disabled>
<IconButton
tooltip={appendInParentheses(
tr.editingAttachPicturesaudiovideo(),
shortcutLabel
)}
iconSize={70}
{disabled}
on:click={onAttachment}
on:mount={createShortcut}
>
{@html paperclipIcon}
</IconButton>
</OnlyEditable>
</WithShortcut>
</ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut={"F5"} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(tr.editingRecordAudio(), shortcutLabel)}
iconSize={70}
on:click={onRecord}
on:mount={createShortcut}
>
{@html micIcon}
</IconButton>
<OnlyEditable let:disabled>
<IconButton
tooltip={appendInParentheses(
tr.editingRecordAudio(),
shortcutLabel
)}
iconSize={70}
{disabled}
on:click={onRecord}
on:mount={createShortcut}
>
{@html micIcon}
</IconButton>
</OnlyEditable>
</WithShortcut>
</ButtonGroupItem>
@ -70,9 +87,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroupItem>
<WithDropdownMenu let:createDropdown let:menuId>
<IconButton on:mount={createDropdown}>
{@html functionIcon}
</IconButton>
<WithContext key={disabledKey} let:context={disabled}>
<IconButton {disabled} on:mount={createDropdown}>
{@html functionIcon}
</IconButton>
</WithContext>
<DropdownMenu id={menuId}>
<WithShortcut
@ -81,7 +100,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let:shortcutLabel
>
<DropdownItem
on:click={() => wrap("\\(", "\\)")}
on:click={() => wrapCurrent("\\(", "\\)")}
on:mount={createShortcut}
>
{tr.editingMathjaxInline()}
@ -95,7 +114,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let:shortcutLabel
>
<DropdownItem
on:click={() => wrap("\\[", "\\]")}
on:click={() => wrapCurrent("\\[", "\\]")}
on:mount={createShortcut}
>
{tr.editingMathjaxBlock()}
@ -109,7 +128,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let:shortcutLabel
>
<DropdownItem
on:click={() => wrap("\\(\\ce{", "}\\)")}
on:click={() => wrapCurrent("\\(\\ce{", "}\\)")}
on:mount={createShortcut}
>
{tr.editingMathjaxChemistry()}
@ -123,7 +142,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let:shortcutLabel
>
<DropdownItem
on:click={() => wrap("[latex]", "[/latex]")}
on:click={() => wrapCurrent("[latex]", "[/latex]")}
on:mount={createShortcut}
>
{tr.editingLatex()}
@ -137,7 +156,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let:shortcutLabel
>
<DropdownItem
on:click={() => wrap("[$]", "[/$]")}
on:click={() => wrapCurrent("[$]", "[/$]")}
on:mount={createShortcut}
>
{tr.editingLatexEquation()}
@ -151,7 +170,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let:shortcutLabel
>
<DropdownItem
on:click={() => wrap("[$$]", "[/$$]")}
on:click={() => wrapCurrent("[$$]", "[/$$]")}
on:mount={createShortcut}
>
{tr.editingLatexMathEnv()}
@ -163,15 +182,28 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</ButtonGroupItem>
<ButtonGroupItem>
<WithShortcut shortcut={"Control+Shift+X"} let:createShortcut let:shortcutLabel>
<IconButton
tooltip={appendInParentheses(tr.editingHtmlEditor(), shortcutLabel)}
iconSize={70}
on:click={onHtmlEdit}
on:mount={createShortcut}
>
{@html xmlIcon}
</IconButton>
</WithShortcut>
<WithContext key={disabledKey} let:context={disabled}>
<WithContext key={inCodableKey} let:context={inCodable}>
<WithShortcut
shortcut={"Control+Shift+X"}
let:createShortcut
let:shortcutLabel
>
<IconButton
tooltip={appendInParentheses(
tr.editingHtmlEditor(),
shortcutLabel
)}
iconSize={70}
active={!disabled && inCodable}
{disabled}
on:click={onHtmlEdit}
on:mount={createShortcut}
>
{@html xmlIcon}
</IconButton>
</WithShortcut>
</WithContext>
</WithContext>
</ButtonGroupItem>
</ButtonGroup>

View file

@ -41,6 +41,6 @@ export function saveNow(keepFocus: boolean): void {
saveField(currentField, "key");
} else {
// triggers onBlur, which saves
currentField.blurEditable();
currentField.blur();
}
}

89
ts/editor/codable.ts Normal file
View file

@ -0,0 +1,89 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as CodeMirror from "codemirror/lib/codemirror";
import "codemirror/mode/htmlmixed/htmlmixed";
import "codemirror/addon/fold/foldcode";
import "codemirror/addon/fold/foldgutter";
import "codemirror/addon/fold/xml-fold";
import "codemirror/addon/edit/matchtags.js";
import "codemirror/addon/edit/closetag.js";
import { setCodableButtons } from "./toolbar";
const codeMirrorOptions = {
mode: "htmlmixed",
theme: "monokai",
lineNumbers: true,
lineWrapping: true,
foldGutter: true,
gutters: ["CodeMirror-linenumbers", "CodeMirror-foldgutter"],
matchTags: { bothTags: true },
autoCloseTags: true,
extraKeys: { Tab: false, "Shift-Tab": false },
viewportMargin: Infinity,
};
const parser = new DOMParser();
function parseHTML(html: string): string {
const doc = parser.parseFromString(html, "text/html");
return doc.documentElement.innerHTML;
}
export class Codable extends HTMLTextAreaElement {
codeMirror: CodeMirror | undefined;
active: boolean;
constructor() {
super();
this.active = false;
}
set fieldHTML(content: string) {
this.value = content;
}
get fieldHTML(): string {
return parseHTML(this.codeMirror.getValue());
}
connectedCallback(): void {
this.setAttribute("hidden", "");
}
setup(html: string): void {
this.active = true;
this.fieldHTML = html;
this.codeMirror = CodeMirror.fromTextArea(this, codeMirrorOptions);
}
teardown(): string {
this.active = false;
this.codeMirror.toTextArea();
this.codeMirror = undefined;
return parseHTML(this.value);
}
focus(): void {
this.codeMirror.focus();
setCodableButtons();
}
caretToEnd(): void {
this.codeMirror.setCursor(this.codeMirror.lineCount(), 0);
}
surroundSelection(before: string, after: string): void {
const selection = this.codeMirror.getSelection();
this.codeMirror.replaceSelection(before + selection + after);
}
onEnter(): void {
/* default */
}
onPaste(): void {
/* default */
}
}

4
ts/editor/contextKeys.ts Normal file
View file

@ -0,0 +1,4 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export const inCodableKey = Symbol("inCodable");

View file

@ -37,3 +37,16 @@ img.drawing {
filter: unquote("invert() hue-rotate(180deg)");
}
}
[hidden] {
display: none;
}
@import "ts/sass/codemirror/lib/codemirror";
@import "ts/sass/codemirror/theme/monokai";
@import "ts/sass/codemirror/addon/fold/foldgutter";
.CodeMirror {
height: auto;
padding: 6px 0;
}

View file

@ -1,7 +1,10 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { nodeIsInline } from "./helpers";
import { bridgeCommand } from "./lib";
import { nodeIsInline, caretToEnd, getBlockElement } from "./helpers";
import { setEditableButtons } from "./toolbar";
import { wrap } from "./wrap";
function containsInlineContent(field: Element): boolean {
if (field.childNodes.length === 0) {
@ -36,4 +39,32 @@ export class Editable extends HTMLElement {
connectedCallback(): void {
this.setAttribute("contenteditable", "");
}
focus(): void {
super.focus();
setEditableButtons();
}
caretToEnd(): void {
caretToEnd(this);
}
surroundSelection(before: string, after: string): void {
wrap(before, after);
}
onEnter(event: KeyboardEvent): void {
if (
!getBlockElement(this.getRootNode() as Document | ShadowRoot) !==
event.shiftKey
) {
event.preventDefault();
document.execCommand("insertLineBreak");
}
}
onPaste(event: ClipboardEvent): void {
bridgeCommand("paste");
event.preventDefault();
}
}

View file

@ -6,23 +6,20 @@
*/
import type { Editable } from "./editable";
import type { Codable } from "./codable";
import { updateActiveButtons } from "./toolbar";
import { bridgeCommand } from "./lib";
import { onInput, onKey, onKeyUp } from "./inputHandlers";
import { onFocus, onBlur } from "./focusHandlers";
function onPaste(evt: ClipboardEvent): void {
bridgeCommand("paste");
evt.preventDefault();
}
function onCutOrCopy(): void {
bridgeCommand("cutOrCopy");
}
export class EditingArea extends HTMLDivElement {
editable: Editable;
codable: Codable;
baseStyle: HTMLStyleElement;
constructor() {
@ -41,6 +38,17 @@ export class EditingArea extends HTMLDivElement {
this.editable = document.createElement("anki-editable") as Editable;
this.shadowRoot!.appendChild(this.editable);
this.codable = document.createElement("textarea", {
is: "anki-codable",
}) as Codable;
this.shadowRoot!.appendChild(this.codable);
this.onPaste = this.onPaste.bind(this);
}
get activeInput(): Editable | Codable {
return this.codable.active ? this.codable : this.editable;
}
get ord(): number {
@ -48,11 +56,11 @@ export class EditingArea extends HTMLDivElement {
}
set fieldHTML(content: string) {
this.editable.fieldHTML = content;
this.activeInput.fieldHTML = content;
}
get fieldHTML(): string {
return this.editable.fieldHTML;
return this.activeInput.fieldHTML;
}
connectedCallback(): void {
@ -61,7 +69,7 @@ export class EditingArea extends HTMLDivElement {
this.addEventListener("input", onInput);
this.addEventListener("focus", onFocus);
this.addEventListener("blur", onBlur);
this.addEventListener("paste", onPaste);
this.addEventListener("paste", this.onPaste);
this.addEventListener("copy", onCutOrCopy);
this.addEventListener("oncut", onCutOrCopy);
this.addEventListener("mouseup", updateActiveButtons);
@ -76,7 +84,7 @@ export class EditingArea extends HTMLDivElement {
this.removeEventListener("input", onInput);
this.removeEventListener("focus", onFocus);
this.removeEventListener("blur", onBlur);
this.removeEventListener("paste", onPaste);
this.removeEventListener("paste", this.onPaste);
this.removeEventListener("copy", onCutOrCopy);
this.removeEventListener("oncut", onCutOrCopy);
this.removeEventListener("mouseup", updateActiveButtons);
@ -107,15 +115,65 @@ export class EditingArea extends HTMLDivElement {
return firstRule.style.direction === "rtl";
}
focus(): void {
this.activeInput.focus();
}
blur(): void {
this.activeInput.blur();
}
caretToEnd(): void {
this.activeInput.caretToEnd();
}
hasFocus(): boolean {
return document.activeElement === this;
}
getSelection(): Selection {
return this.shadowRoot!.getSelection()!;
}
focusEditable(): void {
this.editable.focus();
surroundSelection(before: string, after: string): void {
this.activeInput.surroundSelection(before, after);
}
onEnter(event: KeyboardEvent): void {
this.activeInput.onEnter(event);
}
onPaste(event: ClipboardEvent): void {
this.activeInput.onPaste(event);
}
toggleHtmlEdit(): void {
const hadFocus = this.hasFocus();
if (this.codable.active) {
this.fieldHTML = this.codable.teardown();
this.editable.hidden = false;
} else {
this.editable.hidden = true;
this.codable.setup(this.fieldHTML);
}
if (hadFocus) {
this.focus();
this.caretToEnd();
}
}
/**
* @deprecated Use focus instead
*/
focusEditable(): void {
focus();
}
/**
* @deprecated Use blur instead
*/
blurEditable(): void {
this.editable.blur();
blur();
}
}

View file

@ -9,7 +9,7 @@ import { bridgeCommand } from "./lib";
export function onFocus(evt: FocusEvent): void {
const currentField = evt.currentTarget as EditingArea;
currentField.focusEditable();
currentField.focus();
bridgeCommand(`focus:${currentField.ord}`);
enableButtons();
}

View file

@ -1,7 +1,9 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { EditingArea } from "./editingArea";
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
export function nodeIsElement(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE;
@ -69,11 +71,11 @@ export function nodeIsInline(node: Node): boolean {
return !nodeIsElement(node) || INLINE_TAGS.includes(node.tagName);
}
export function caretToEnd(currentField: EditingArea): void {
export function caretToEnd(node: Node): void {
const range = document.createRange();
range.selectNodeContents(currentField.editable);
range.selectNodeContents(node);
range.collapse(false);
const selection = currentField.getSelection();
const selection = (node.getRootNode() as Document | ShadowRoot).getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
}

View file

@ -11,13 +11,13 @@ import { setupI18n, ModuleName } from "lib/i18n";
import "./fields.css";
import { caretToEnd } from "./helpers";
import { saveField } from "./changeTimer";
import { EditorField } from "./editorField";
import { LabelContainer } from "./labelContainer";
import { EditingArea } from "./editingArea";
import { Editable } from "./editable";
import { Codable } from "./codable";
import { initToolbar } from "./toolbar";
export { setNoteId, getNoteId } from "./noteId";
@ -35,6 +35,7 @@ declare global {
}
customElements.define("anki-editable", Editable);
customElements.define("anki-codable", Codable, { extends: "textarea" });
customElements.define("anki-editing-area", EditingArea, { extends: "div" });
customElements.define("anki-label-container", LabelContainer, { extends: "div" });
customElements.define("anki-editor-field", EditorField, { extends: "div" });
@ -49,8 +50,8 @@ export function focusField(n: number): void {
const field = getEditorField(n);
if (field) {
field.editingArea.focusEditable();
caretToEnd(field.editingArea);
field.editingArea.focus();
field.editingArea.caretToEnd();
updateActiveButtons(new Event("manualfocus"));
}
}
@ -60,7 +61,7 @@ export function focusIfField(x: number, y: number): boolean {
for (let i = 0; i < elements.length; i++) {
const elem = elements[i] as EditingArea;
if (elem instanceof EditingArea) {
elem.focusEditable();
elem.focus();
return true;
}
}

View file

@ -7,7 +7,7 @@
import { updateActiveButtons } from "./toolbar";
import { EditingArea } from "./editingArea";
import { caretToEnd, nodeIsElement, getBlockElement } from "./helpers";
import { nodeIsElement } from "./helpers";
import { triggerChangeTimer } from "./changeTimer";
import { registerShortcut } from "lib/shortcuts";
@ -22,17 +22,12 @@ export function onKey(evt: KeyboardEvent): void {
// esc clears focus, allowing dialog to close
if (evt.code === "Escape") {
currentField.blurEditable();
return;
return currentField.blur();
}
// prefer <br> instead of <div></div>
if (
evt.code === "Enter" &&
!getBlockElement(currentField.shadowRoot!) !== evt.shiftKey
) {
evt.preventDefault();
document.execCommand("insertLineBreak");
if (evt.code === "Enter") {
return currentField.onEnter(evt);
}
// // fix Ctrl+right/left handling in RTL fields
@ -59,7 +54,7 @@ export function onKey(evt: KeyboardEvent): void {
function updateFocus(evt: FocusEvent) {
const newFocusTarget = evt.target;
if (newFocusTarget instanceof EditingArea) {
caretToEnd(newFocusTarget);
newFocusTarget.caretToEnd();
updateActiveButtons(evt);
}
}

View file

@ -7,12 +7,14 @@
*/
import { disabledKey, nightModeKey } from "components/contextKeys";
import { inCodableKey } from "./contextKeys";
import { writable } from "svelte/store";
import EditorToolbar from "./EditorToolbar.svelte";
import "./bootstrap.css";
const disabled = writable(false);
const inCodable = writable(false);
export function initToolbar(i18n: Promise<void>): Promise<EditorToolbar> {
let toolbarResolve: (value: EditorToolbar) => void;
@ -27,6 +29,7 @@ export function initToolbar(i18n: Promise<void>): Promise<EditorToolbar> {
const context = new Map();
context.set(disabledKey, disabled);
context.set(inCodableKey, inCodable);
context.set(
nightModeKey,
document.documentElement.classList.contains("night-mode")
@ -47,6 +50,14 @@ export function disableButtons(): void {
disabled.set(true);
}
export function setCodableButtons(): void {
inCodable.set(true);
}
export function setEditableButtons(): void {
inCodable.set(false);
}
export {
updateActiveButtons,
clearActiveButtons,

View file

@ -45,6 +45,11 @@ export function wrap(front: string, back: string): void {
wrapInternal(front, back, false);
}
export function wrapCurrent(front: string, back: string): void {
const currentField = getCurrentField()!;
currentField.surroundSelection(front, back);
}
/* currently unused */
export function wrapIntoText(front: string, back: string): void {
wrapInternal(front, back, true);

View file

@ -147,6 +147,15 @@
"path": "node_modules/bootstrap",
"licenseFile": "node_modules/bootstrap/LICENSE"
},
"codemirror@5.61.1": {
"licenses": "MIT",
"repository": "https://github.com/codemirror/CodeMirror",
"publisher": "Marijn Haverbeke",
"email": "marijnh@gmail.com",
"url": "http://marijnhaverbeke.nl",
"path": "node_modules/codemirror",
"licenseFile": "node_modules/codemirror/LICENSE"
},
"commander@7.2.0": {
"licenses": "MIT",
"repository": "https://github.com/tj/commander.js",

View file

@ -12,6 +12,7 @@
"@sqltools/formatter": "^1.2.2",
"@tsconfig/svelte": "^1.0.10",
"@types/bootstrap": "^5.0.12",
"@types/codemirror": "^5.60.0",
"@types/d3": "^6.3.0",
"@types/diff": "^5.0.0",
"@types/jest": "^26.0.22",
@ -60,6 +61,7 @@
"@types/marked": "^2.0.2",
"bootstrap": "=5.0.0-beta3",
"bootstrap-icons": "^1.4.0",
"codemirror": "^5.61.1",
"css-browser-selector": "^0.6.5",
"d3": "^7.0.0",
"intl-pluralrules": "^1.2.2",

View file

@ -8,7 +8,7 @@ sass_library(
"bootstrap-dark.scss",
],
visibility = ["//visibility:public"],
deps = ["//ts/sass/bootstrap"],
deps = ["//ts/sass/bootstrap", "//ts/sass/codemirror"],
)
sass_library(

View file

@ -0,0 +1,24 @@
load("//ts:vendor.bzl", "pkg_from_name", "vendor_js_lib")
load("@io_bazel_rules_sass//:defs.bzl", "sass_library")
# copy codemirror sass files in
vendor_js_lib(
name = "sass-sources",
include = [
"lib/codemirror.css",
"theme",
"addon/fold/foldgutter.css",
],
base = "external/npm/node_modules/codemirror/",
pkg = pkg_from_name("codemirror"),
visibility = ["//visibility:private"],
)
# wrap them in a library
sass_library(
name = "codemirror",
srcs = [
":sass-sources",
],
visibility = ["//visibility:public"],
)

View file

@ -701,6 +701,13 @@
"@popperjs/core" "^2.9.2"
"@types/jquery" "*"
"@types/codemirror@^5.60.0":
version "5.60.0"
resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-5.60.0.tgz#bf14b728449ebd355c17054262a083639a995710"
integrity sha512-xgzXZyCzedLRNC67/Nn8rpBtTFnAsX2C+Q/LGoH6zgcpF/LqdNHJMHEOhqT1bwUcSp6kQdOIuKzRbeW9DYhEhg==
dependencies:
"@types/tern" "*"
"@types/d3-array@*":
version "2.12.1"
resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-2.12.1.tgz#bee6857b812f1ecfd5e6832fd67f617b667dd024"
@ -921,6 +928,11 @@
resolved "https://registry.yarnpkg.com/@types/diff/-/diff-5.0.0.tgz#eb71e94feae62548282c4889308a3dfb57e36020"
integrity sha512-jrm2K65CokCCX4NmowtA+MfXyuprZC13jbRuwprs6/04z/EcFg/MCwYdsHn+zgV4CQBiATiI7AEq7y1sZCtWKA==
"@types/estree@*":
version "0.0.48"
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.48.tgz#18dc8091b285df90db2f25aa7d906cfc394b7f74"
integrity sha512-LfZwXoGUDo0C3me81HXgkBg5CTQYb6xzEl+fNmbO4JdRiSKQ8A0GD1OBBvKAIsbCUgoyAty7m99GqqMQe784ew==
"@types/geojson@*":
version "7946.0.7"
resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.7.tgz#c8fa532b60a0042219cdf173ca21a975ef0666ad"
@ -1069,6 +1081,13 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff"
integrity sha512-RJJrrySY7A8havqpGObOB4W92QXKJo63/jFLLgpvOtsGUqbQZ9Sbgl35KMm1DjC6j7AvmmU2bIno+3IyEaemaw==
"@types/tern@*":
version "0.23.3"
resolved "https://registry.yarnpkg.com/@types/tern/-/tern-0.23.3.tgz#4b54538f04a88c9ff79de1f6f94f575a7f339460"
integrity sha512-imDtS4TAoTcXk0g7u4kkWqedB3E4qpjXzCpD2LU5M5NAXHzCDsypyvXSaG7mM8DKYkCRa7tFp4tS/lp/Wo7Q3w==
dependencies:
"@types/estree" "*"
"@types/yargs-parser@*":
version "20.2.0"
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.0.tgz#dd3e6699ba3237f0348cd085e4698780204842f9"
@ -1516,6 +1535,11 @@ co@^4.6.0:
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
integrity sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=
codemirror@^5.61.1:
version "5.61.1"
resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.61.1.tgz#ccfc8a43b8fcfb8b12e8e75b5ffde48d541406e0"
integrity sha512-+D1NZjAucuzE93vJGbAaXzvoBHwp9nJZWWWF9utjv25+5AZUiah6CIlfb4ikG4MoDsFsCG8niiJH5++OO2LgIQ==
collect-v8-coverage@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.1.tgz#cc2c8e94fc18bbdffe64d6534570c8a673b27f59"