@portabletext/editor
Version:
Portable Text Editor made in React
391 lines (390 loc) • 11.9 kB
JavaScript
import { defineBehavior, raise, isHotkey, execute, effect, noop } from "../_chunks-es/behavior.core.js";
import { coreBehaviors } from "../_chunks-es/behavior.core.js";
import { getFirstBlock, getSelectedBlocks, getLastBlock, getFocusTextBlock, isSelectionCollapsed, getFocusSpan } from "../_chunks-es/selector.is-overlapping-selection.js";
import { createActor, setup, assign, assertEvent } from "xstate";
import { getBlockTextBefore } from "../_chunks-es/selector.get-text-before.js";
import { createMarkdownBehaviors } from "../_chunks-es/behavior.markdown.js";
function createCodeEditorBehaviors(config) {
return [defineBehavior({
on: "keyboard.keydown",
guard: ({
snapshot,
event
}) => {
const isMoveUpShortcut = isHotkey(config.moveBlockUpShortcut, event.originEvent), firstBlock = getFirstBlock(snapshot), selectedBlocks = getSelectedBlocks(snapshot), blocksAbove = firstBlock?.node._key !== selectedBlocks[0]?.node._key;
return !isMoveUpShortcut || !blocksAbove ? !1 : {
paths: selectedBlocks.map((block) => block.path)
};
},
actions: [(_, {
paths
}) => paths.map((at) => raise({
type: "move.block up",
at
}))]
}), defineBehavior({
on: "keyboard.keydown",
guard: ({
snapshot,
event
}) => {
const isMoveDownShortcut = isHotkey(config.moveBlockDownShortcut, event.originEvent), lastBlock = getLastBlock(snapshot), selectedBlocks = getSelectedBlocks(snapshot), blocksBelow = lastBlock?.node._key !== selectedBlocks[selectedBlocks.length - 1]?.node._key;
return !isMoveDownShortcut || !blocksBelow ? !1 : {
paths: selectedBlocks.map((block) => block.path).reverse()
};
},
actions: [(_, {
paths
}) => paths.map((at) => raise({
type: "move.block down",
at
}))]
})];
}
const emojiCharRegEx = /^[a-zA-Z-_0-9]{1}$/, incompleteEmojiRegEx = /:([a-zA-Z-_0-9]+)$/, emojiRegEx = /:([a-zA-Z-_0-9]+):$/;
function createEmojiPickerBehaviors(config) {
const emojiPickerActor = createActor(createEmojiPickerMachine());
return emojiPickerActor.start(), emojiPickerActor.subscribe((state) => {
config.onMatchesChanged({
matches: state.context.matches
}), config.onSelectedIndexChanged({
selectedIndex: state.context.selectedIndex
});
}), [defineBehavior({
on: "insert.text",
guard: ({
snapshot,
event
}) => {
if (event.text === ":")
return !1;
if (!emojiCharRegEx.test(event.text))
return {
emojis: []
};
const focusBlock = getFocusTextBlock(snapshot), emojiKeyword = `${getBlockTextBefore(snapshot)}${event.text}`.match(incompleteEmojiRegEx)?.[1];
return !focusBlock || emojiKeyword === void 0 ? {
emojis: []
} : {
emojis: config.matchEmojis({
keyword: emojiKeyword
})
};
},
actions: [(_, params) => [{
type: "effect",
effect: () => {
emojiPickerActor.send({
type: "emojis found",
matches: params.emojis
});
}
}]]
}), defineBehavior({
on: "insert.text",
guard: ({
snapshot,
event
}) => {
if (event.text !== ":")
return !1;
const matches = emojiPickerActor.getSnapshot().context.matches, selectedIndex = emojiPickerActor.getSnapshot().context.selectedIndex, emoji = matches[selectedIndex] ? config.parseMatch({
match: matches[selectedIndex]
}) : void 0, focusBlock = getFocusTextBlock(snapshot), textBefore = getBlockTextBefore(snapshot), emojiKeyword = `${textBefore}:`.match(emojiRegEx)?.[1];
if (!focusBlock || emojiKeyword === void 0)
return !1;
const emojiStringLength = emojiKeyword.length + 2;
return emoji ? {
focusBlock,
emoji,
emojiStringLength,
textBeforeLength: textBefore.length + 1
} : !1;
},
actions: [() => [execute({
type: "insert.text",
text: ":"
})], (_, params) => [effect(() => {
emojiPickerActor.send({
type: "select"
});
}), execute({
type: "delete.text",
at: {
anchor: {
path: params.focusBlock.path,
offset: params.textBeforeLength - params.emojiStringLength
},
focus: {
path: params.focusBlock.path,
offset: params.textBeforeLength
}
}
}), execute({
type: "insert.text",
text: params.emoji
})]]
}), defineBehavior({
on: "keyboard.keydown",
guard: ({
snapshot,
event
}) => {
const matches = emojiPickerActor.getSnapshot().context.matches;
if (matches.length === 0)
return !1;
if (isHotkey("Escape", event.originEvent))
return {
action: "reset"
};
const isEnter = isHotkey("Enter", event.originEvent), isTab = isHotkey("Tab", event.originEvent);
if (isEnter || isTab) {
const selectedIndex = emojiPickerActor.getSnapshot().context.selectedIndex, emoji = matches[selectedIndex] ? config.parseMatch({
match: matches[selectedIndex]
}) : void 0;
if (!emoji)
return !1;
const focusBlock = getFocusTextBlock(snapshot), textBefore = getBlockTextBefore(snapshot), emojiKeyword = textBefore.match(incompleteEmojiRegEx)?.[1];
if (!focusBlock || emojiKeyword === void 0)
return !1;
const emojiStringLength = emojiKeyword.length + 1;
return emoji ? {
action: "select",
focusBlock,
emoji,
emojiStringLength,
textBeforeLength: textBefore.length
} : !1;
}
const isArrowDown = isHotkey("ArrowDown", event.originEvent), isArrowUp = isHotkey("ArrowUp", event.originEvent);
return isArrowDown && matches.length > 0 ? {
action: "navigate down"
} : isArrowUp && matches.length > 0 ? {
action: "navigate up"
} : !1;
},
actions: [(_, params) => params.action === "select" ? [effect(() => {
emojiPickerActor.send({
type: "select"
});
}), execute({
type: "delete.text",
at: {
anchor: {
path: params.focusBlock.path,
offset: params.textBeforeLength - params.emojiStringLength
},
focus: {
path: params.focusBlock.path,
offset: params.textBeforeLength
}
}
}), execute({
type: "insert.text",
text: params.emoji
})] : params.action === "navigate up" ? [
// If we are navigating then we want to hijack the key event and
// turn it into a noop.
noop(),
effect(() => {
emojiPickerActor.send({
type: "navigate up"
});
})
] : params.action === "navigate down" ? [
// If we are navigating then we want to hijack the key event and
// turn it into a noop.
noop(),
effect(() => {
emojiPickerActor.send({
type: "navigate down"
});
})
] : [effect(() => {
emojiPickerActor.send({
type: "reset"
});
})]]
}), defineBehavior({
on: "delete.backward",
guard: ({
snapshot,
event
}) => {
if (event.unit !== "character" || emojiPickerActor.getSnapshot().context.matches.length === 0)
return !1;
const focusBlock = getFocusTextBlock(snapshot), textBefore = getBlockTextBefore(snapshot), emojiKeyword = textBefore.slice(0, textBefore.length - 1).match(incompleteEmojiRegEx)?.[1];
return !focusBlock || emojiKeyword === void 0 ? {
emojis: []
} : {
emojis: config.matchEmojis({
keyword: emojiKeyword
})
};
},
actions: [(_, params) => [{
type: "effect",
effect: () => {
emojiPickerActor.send({
type: "emojis found",
matches: params.emojis
});
}
}]]
})];
}
function createEmojiPickerMachine() {
return setup({
types: {
context: {},
events: {}
},
actions: {
"assign matches": assign({
matches: ({
event
}) => (assertEvent(event, "emojis found"), event.matches)
}),
"reset matches": assign({
matches: []
}),
"reset selected index": assign({
selectedIndex: 0
}),
"increment selected index": assign({
selectedIndex: ({
context
}) => context.selectedIndex === context.matches.length - 1 ? 0 : context.selectedIndex + 1
}),
"decrement selected index": assign({
selectedIndex: ({
context
}) => context.selectedIndex === 0 ? context.matches.length - 1 : context.selectedIndex - 1
})
},
guards: {
"no matches": ({
context
}) => context.matches.length === 0
}
}).createMachine({
id: "emoji picker",
context: {
matches: [],
selectedIndex: 0
},
initial: "idle",
states: {
idle: {
on: {
"emojis found": {
actions: "assign matches",
target: "showing matches"
}
}
},
"showing matches": {
always: {
guard: "no matches",
target: "idle"
},
exit: ["reset selected index"],
on: {
"emojis found": {
actions: "assign matches"
},
"navigate down": {
actions: "increment selected index"
},
"navigate up": {
actions: "decrement selected index"
},
reset: {
target: "idle",
actions: ["reset selected index", "reset matches"]
},
select: {
target: "idle",
actions: ["reset selected index", "reset matches"]
}
}
}
}
});
}
function looksLikeUrl(text) {
let looksLikeUrl2 = !1;
try {
const url = new URL(text);
if (!sensibleProtocols.includes(url.protocol))
return !1;
looksLikeUrl2 = !0;
} catch {
}
return looksLikeUrl2;
}
const sensibleProtocols = ["http:", "https:", "mailto:", "tel:"];
function createLinkBehaviors(config) {
const pasteLinkOnSelection = defineBehavior({
on: "clipboard.paste",
guard: ({
snapshot,
event
}) => {
const selectionCollapsed = isSelectionCollapsed(snapshot), text = event.originEvent.dataTransfer.getData("text/plain"), url = looksLikeUrl(text) ? text : void 0, annotation = url !== void 0 ? config.linkAnnotation?.({
url,
schema: snapshot.context.schema
}) : void 0;
return annotation && !selectionCollapsed ? {
annotation
} : !1;
},
actions: [(_, {
annotation
}) => [execute({
type: "annotation.add",
annotation
})]]
}), pasteLinkAtCaret = defineBehavior({
on: "clipboard.paste",
guard: ({
snapshot,
event
}) => {
const focusSpan = getFocusSpan(snapshot), selectionCollapsed = isSelectionCollapsed(snapshot);
if (!focusSpan || !selectionCollapsed)
return !1;
const text = event.originEvent.dataTransfer.getData("text/plain"), url = looksLikeUrl(text) ? text : void 0, annotation = url !== void 0 ? config.linkAnnotation?.({
url,
schema: snapshot.context.schema
}) : void 0;
return url && annotation && selectionCollapsed ? {
focusSpan,
annotation,
url
} : !1;
},
actions: [(_, {
annotation,
url
}) => [execute({
type: "insert.span",
text: url,
annotations: [annotation]
})]]
});
return [pasteLinkOnSelection, pasteLinkAtCaret];
}
export {
coreBehaviors,
createCodeEditorBehaviors,
createEmojiPickerBehaviors,
createLinkBehaviors,
createMarkdownBehaviors,
defineBehavior,
effect,
execute,
noop,
raise
};
//# sourceMappingURL=index.js.map