間違いなく、Notion.soは、メモの整理に関して私のお気に入りのアプリの1つです。不要なUI要素ではなく、コンテンツに注意を向けるため、最小限のデザインのテキストエディターが大好きです。
それでも、それはあなたにより良い執筆体験につながるたくさんの強力な機能を提供します。
たとえば、スラッシュコマンドは、私の書き込みフローを実際に強化した機能の1つです。キーボードから離れることなく、コンテンツを追加してスタイルを設定できます。UIボタンをクリックして新しい見出しを追加する代わりに/h1
、「Enter」を押して入力するだけで、そこに移動できます。
Notionのもう1つの優れた点は、完全にWebベースのアプリケーションであるということです。そのため、実際にどのように構築されているのか、特にテキストエディタに興味がありました。実は、思ったほど難しくないことに気づきました。
この記事では、Notionのようなテキストエディターがどのように機能し、React.jsを使用して自分で作成する方法について詳しく見ていきます。
これを行うことにより、いくつかの重要なフロントエンドスキルを発見して学習します。
Notionのようなテキストエディタのコアコンセプトは、私がブロックと呼びたいものです。キーボードの「Enter」を押すたびに、新しいブロックが作成されます。つまり、基本的に、各ページは1つまたは複数のブロックで構成されます。
ユーザーの観点からは、ブロックにはコンテンツが含まれ、ブロックタイプに関連付けられた特定のスタイルがあります。ブロックのタイプは、見出し、引用符、または段落です。それぞれに独自のスタイリングがあります。
技術的な観点から、ブロックはいわゆるcontenteditable
要素です。ほぼすべてのHTML要素を編集可能な要素に変えることができます。contenteditable="true"
属性を追加するだけです。これは、要素をユーザーが編集できるようにする必要があるかどうかを示します。その場合、ユーザーは、input
またはtextarea
要素であるかのように、HTMLドキュメント内のコンテンツを直接編集できます。
概念は例で明らかになります:
ユーザーが見出しのタイプで新しいブロックを追加するとします。これにより、次のHTML出力が生成されます。
<h1 contenteditable="true"> I am editable by the user </h1>
ユーザーが「Enter」を押してページに新しいブロックを追加できるようになったので、追加されたブロックのタイプをどのように判断できますか?
Notionでは、スラッシュコマンドを使用してこれを行うことができます。
スラッシュコマンドの概念はシンプルでありながら効果的です。/
ブロック内を入力するたびに、カーソルのすぐ上に先行入力メニューがポップアップ表示されます。このメニューには、使用可能なすべてのブロックタイプが表示されます。書きながら、クエリでブロックタイプをフィルタリングします。正しいブロックタイプを見つけたら、「Enter」を押すだけで、ブロックは選択したものに変わります。
このアプリケーションでは、すべてのスラッシュコマンドは対応するHTML要素と同等になります。たとえば/h1
、/p
コマンドがH1見出しに変わり、コマンドが段落に変わることを意味します。
技術的には、のような既存のブロックは、ユーザーが「Enter」を入力して押すと、<h1 contenteditable="true"> </h1>
結果的にに<p contenteditable="true"> </p>
なります/p
。
理論的にどのように機能するかがわかったので、それを実践してみましょう。まず、新しいReactプロジェクトを作成します。create-react-appまたはその他のツールを使用してこれを行うことができます。
セットアップが完了したら、最初のコンポーネントを作成します。 editablePage.js
// Imports | |
const initialBlock = { id: uid(), html: "", tag: "p" }; | |
class EditablePage extends React.Component { | |
constructor(props) { | |
super(props); | |
this.state = { blocks: [initialBlock] }; | |
} | |
render() { | |
return ( | |
<div className="Page"> | |
{this.state.blocks.map((block, key) => { | |
return ( | |
<div key={key} id={block.id}> | |
Tag: {block.tag}, Content: {block.html} | |
</div> | |
); | |
})} | |
</div> | |
); | |
} | |
} | |
export default EditablePage; |
ページコンポーネントは、後で作成するすべてのブロックを格納およびレンダリングします。最初に、一番上で定義する最初の最初のブロックのみを提供します。編集可能なブロックコンポーネントを作成すると、このブロックは空の段落要素に変わります。今のところ、プレーンdiv
コンテナをレンダリングするだけです。
注:すべてのブロックには一意のIDが必要なuid()
ため、新しいブロックの初期化に使用できるヘルパー関数を作成しました。同じ機能を使用したい場合:
次のステップとして、div
コンテナの代わりにレンダリングできる編集可能なブロックコンポーネントを作成します。そのためには、と呼ばれる外部依存関係をインストールする必要がありreact-contenteditable
ます。
React-Contenteditableは、Reactで編集可能な要素を非常に簡単に操作できるようにするパッケージです。それは私たちにとって多くの複雑さを抽象化するので、適切な小道具をContentEditable
コンポーネントに渡すだけで済みます。パッケージによって公開されたコンポーネントが残りを処理します。
パッケージをインストールするには、 npm i react-contenteditable
次に、editableBlock.js
:という名前の新しいファイルを作成します。
// Imports | |
class EditableBlock extends React.Component { | |
constructor(props) { | |
super(props); | |
this.onChangeHandler = this.onChangeHandler.bind(this); | |
this.contentEditable = React.createRef(); | |
this.state = { | |
html: "", | |
tag: "p", | |
}; | |
} | |
componentDidMount() { | |
this.setState({ html: this.props.html, tag: this.props.tag }); | |
} | |
componentDidUpdate(prevProps, prevState) { | |
const htmlChanged = prevState.html !== this.state.html; | |
const tagChanged = prevState.tag !== this.state.tag; | |
if (htmlChanged || tagChanged) { | |
this.props.updatePage({ | |
id: this.props.id, | |
html: this.state.html, | |
tag: this.state.tag | |
}); | |
} | |
} | |
onChangeHandler(e) { | |
this.setState({ html: e.target.value }); | |
} | |
render() { | |
return ( | |
<ContentEditable | |
className="Block" | |
innerRef={this.contentEditable} | |
html={this.state.html} | |
tagName={this.state.tag} | |
onChange={this.onChangeHandler} | |
/> | |
); | |
} | |
} | |
export default EditableBlock; |
編集可能なブロックはContentEditable
、react-contenteditable
パッケージからインポートしたコンポーネントをレンダリングします。渡すことによって、html
およびtagName
コンポーネントへの小道具として、我々は、表示されるべきであるとそれがどのように表示すべきかを定義することができます。tagName
はHTML要素タイプを定義し、したがってスタイルを定義することを忘れないでください。
後でブロックタイプを変更する場合(たとえば、p
からh1
)、tagName
小道具を更新するだけです。とても簡単ですよね?
また、ブロックコンポーネントに高度な状態管理を追加しました。コンポーネントがマウントされると、小道具を介して最初のHTMLコンテンツとタグを受け取り、それらを状態で保存します。今後、ブロックコンポーネントはドラフト状態を完全に所有します。html
またはtagName
propに対するその後の変更はすべて無視されます。
html
or tagName
stateプロパティに関連する更新があった場合、componentDidUpdate
ライフサイクルフックでもページコンポーネントの状態を更新します。したがって、ページの不要な再レンダリングを回避します。
私たちの持つeditableBlock
コンポーネントのセットアップを、我々は最終的に私達のページのコンポーネントでそれを使用することができます。
// Imports | |
const initialBlock = { id: uid(), html: "", tag: "p" }; | |
class EditablePage extends React.Component { | |
constructor(props) { | |
super(props); | |
this.updatePageHandler = this.updatePageHandler.bind(this); | |
this.addBlockHandler = this.addBlockHandler.bind(this); | |
this.deleteBlockHandler = this.deleteBlockHandler.bind(this); | |
this.state = { blocks: [initialBlock] }; | |
} | |
updatePageHandler(updatedBlock) { | |
const blocks = this.state.blocks; | |
const index = blocks.map((b) => b.id).indexOf(updatedBlock.id); | |
const updatedBlocks = [...blocks]; | |
updatedBlocks[index] = { | |
...updatedBlocks[index], | |
tag: updatedBlock.tag, | |
html: updatedBlock.html | |
}; | |
this.setState({ blocks: updatedBlocks }); | |
} | |
addBlockHandler(currentBlock) { | |
const newBlock = { id: uid(), html: "", tag: "p" }; | |
const blocks = this.state.blocks; | |
const index = blocks.map((b) => b.id).indexOf(currentBlock.id); | |
const updatedBlocks = [...blocks]; | |
updatedBlocks.splice(index + 1, 0, newBlock); | |
this.setState({ blocks: updatedBlocks }, () => { | |
currentBlock.ref.nextElementSibling.focus(); | |
}); | |
} | |
deleteBlockHandler(currentBlock) { | |
const previousBlock = currentBlock.ref.previousElementSibling; | |
if (previousBlock) { | |
const blocks = this.state.blocks; | |
const index = blocks.map((b) => b.id).indexOf(currentBlock.id); | |
const updatedBlocks = [...blocks]; | |
updatedBlocks.splice(index, 1); | |
this.setState({ blocks: updatedBlocks }, () => { | |
setCaretToEnd(previousBlock); | |
previousBlock.focus(); | |
}); | |
} | |
} | |
render() { | |
return ( | |
<div className="Page"> | |
{this.state.blocks.map((block, key) => { | |
return ( | |
<EditableBlock | |
key={key} | |
id={block.id} | |
tag={block.tag} | |
html={block.html} | |
updatePage={this.updatePageHandler} | |
addBlock={this.addBlockHandler} | |
deleteBlock={this.deleteBlockHandler} | |
/> | |
); | |
})} | |
</div> | |
); | |
} | |
} | |
export default EditablePage; |
フックEditableBlock
内のコンポーネントを使用するrender
ことに加えて、ページコンポーネントに新しいメソッドを追加しました。
updateBlockHandler
我々はすでに私たちのブロック成分に使用することを同期してページとブロック状態を維持するの世話をします。addBlockHandler
新しいブロックを追加し、それにフォーカスを設定します。deleteBlockHandler
ブロックを削除し、前のブロックにフォーカスを設定します。注:deleteBlockHandler
では、カーソルをブロックコンテンツの最後に手動で設定する別のヘルパー関数を使用します。で行ったように要素にフォーカスするだけの場合は、要素にaddBlockHandler
フォーカスしますが、カーソルはブロックコンテンツの最初に設定されます。同じsetCaretToEnd
ヘルパー関数を使用する場合:
const setCaretToEnd = (element) => { | |
const range = document.createRange(); | |
const selection = window.getSelection(); | |
range.selectNodeContents(element); | |
range.collapse(false); | |
selection.removeAllRanges(); | |
selection.addRange(range); | |
element.focus(); | |
}; |
これまでのところ、最初に追加したブロックにのみテキストコンテンツを追加できます。最初のイベントリスナーを使って、物事をもう少しインタラクティブにしましょう。これにより、必要に応じてブロックを追加および削除できます。
内部editableBlock.js
でonKeyDownHandler
、ContentEditable
コンポーネントに渡すメソッドを作成します。
// Imports | |
class EditableBlock extends React.Component { | |
constructor(props) { | |
super(props); | |
// ... | |
this.onKeyDownHandler = this.onKeyDownHandler.bind(this); | |
this.contentEditable = React.createRef(); | |
this.state = { | |
htmlBackup: null, | |
html: "", | |
tag: "p", | |
previousKey: "" | |
}; | |
} | |
// ... | |
onKeyDownHandler(e) { | |
if (e.key === "/") { | |
this.setState({ htmlBackup: this.state.html }); | |
} | |
if (e.key === "Enter") { | |
if (this.state.previousKey !== "Shift") { | |
e.preventDefault(); | |
this.props.addBlock({ | |
id: this.props.id, | |
ref: this.contentEditable.current | |
}); | |
} | |
} | |
if (e.key === "Backspace" && !this.state.html) { | |
e.preventDefault(); | |
this.props.deleteBlock({ | |
id: this.props.id, | |
ref: this.contentEditable.current | |
}); | |
} | |
this.setState({ previousKey: e.key }); | |
} | |
render() { | |
return ( | |
<ContentEditable | |
className="Block" | |
innerRef={this.contentEditable} | |
html={this.state.html} | |
tagName={this.state.tag} | |
onChange={this.onChangeHandler} | |
onKeyDown={this.onKeyDownHandler} | |
/> | |
); | |
} | |
} | |
export default EditableBlock; |
前述のように、このイベントリスナーは、主にブロックの追加と削除を担当します。しかし、onKeyDownHandler
少しずつ分解してみましょう。
previousKey
キーの組み合わせを検出できるようにする必要があります。/
と、現在のHTMLコンテンツのコピーが状態で保存されます。これは、ユーザーがブロックタイプを選択するプロセスを終了した後、クリーンなHTMLバージョンを復元するためです。Enter
と、デフォルトの動作(つまり、新しい行の追加)が防止されます。代わりaddBlock
に、ページコンポーネントで以前に作成した方法を使用して、新しいブロックを作成します。previousKey
stateプロパティを使用してShift+Enter
キーの組み合わせを検出します。その場合、新しいブロックを追加せず、デフォルトの「Enter」動作を許可します。Backspace
と、空のブロックが削除されますdeleteBlock
。前述のように、ユーザーがa/
を入力すると、選択メニューが表示されます。メニューには、使用可能なすべてのブロックタイプが一覧表示されます。次に、特定のブロックタイプをクリックするか、一致するスラッシュコマンドを入力して、特定のブロックタイプを選択できます。
このような選択メニューを実装するには、最初に別の依存関係を追加する必要があります:match-sorter。これは、一致するブロックタイプのクエリに役立つシンプルなパッケージです。
インストールするには、次のコマンドを実行します。 npm i match-sorter
これが完了したらselectMenu.js
、次の内容で呼び出される新しいファイルを作成します。
// Imports | |
const MENU_HEIGHT = 150; | |
const allowedTags = [ | |
{ | |
id: "page-title", | |
tag: "h1", | |
label: "Page Title" | |
}, | |
{ | |
id: "heading", | |
tag: "h2", | |
label: "Heading" | |
}, | |
{ | |
id: "subheading", | |
tag: "h3", | |
label: "Subheading" | |
}, | |
{ | |
id: "paragraph", | |
tag: "p", | |
label: "Paragraph" | |
} | |
]; | |
class SelectMenu extends React.Component { | |
constructor(props) { | |
super(props); | |
this.keyDownHandler = this.keyDownHandler.bind(this); | |
this.state = { | |
command: "", | |
items: allowedTags, | |
selectedItem: 0 | |
}; | |
} | |
componentDidMount() { | |
document.addEventListener("keydown", this.keyDownHandler); | |
} | |
componentDidUpdate(prevProps, prevState) { | |
const command = this.state.command; | |
if (prevState.command !== command) { | |
const items = matchSorter(allowedTags, command, { keys: ["tag"] }); | |
this.setState({ items: items }); | |
} | |
} | |
componentWillUnmount() { | |
document.removeEventListener("keydown", this.keyDownHandler); | |
} | |
keyDownHandler(e) { | |
const items = this.state.items; | |
const selected = this.state.selectedItem; | |
const command = this.state.command; | |
switch (e.key) { | |
case "Enter": | |
e.preventDefault(); | |
this.props.onSelect(items[selected].tag); | |
break; | |
case "Backspace": | |
if (!command) this.props.close(); | |
this.setState({ command: command.substring(0, command.length - 1) }); | |
break; | |
case "ArrowUp": | |
e.preventDefault(); | |
const prevSelected = selected === 0 ? items.length - 1 : selected - 1; | |
this.setState({ selectedItem: prevSelected }); | |
break; | |
case "ArrowDown": | |
case "Tab": | |
e.preventDefault(); | |
const nextSelected = selected === items.length - 1 ? 0 : selected + 1; | |
this.setState({ selectedItem: nextSelected }); | |
break; | |
default: | |
this.setState({ command: this.state.command + e.key }); | |
break; | |
} | |
} | |
render() { | |
const x = this.props.position.x; | |
const y = this.props.position.y - MENU_HEIGHT; | |
const positionAttributes = { top: y, left: x }; | |
return ( | |
<div className="SelectMenu" style={positionAttributes}> | |
<div className="Items"> | |
{this.state.items.map((item, key) => { | |
const selectedItem = this.state.selectedItem; | |
const isSelected = this.state.items.indexOf(item) === selectedItem; | |
return ( | |
<div | |
className={isSelected ? "Selected" : null} | |
key={key} | |
role="button" | |
tabIndex="0" | |
onClick={() => this.props.onSelect(item.tag)} | |
> | |
{item.label} | |
</div> | |
); | |
})} | |
</div> | |
</div> | |
); | |
} | |
} | |
export default SelectMenu; |
最上部では、ユーザーが選択できるブロックタイプを定義します。各タイプにはタグとラベルがあります。タグは使用するHTML要素タイプを定義し、ラベルはメニュー内の表示名を定義します。
コンポーネントがマウントされると、render
フック内の画面上のメニューの位置が計算されます。そこでは、を使用しますthis.props.position
。このオブジェクトのすぐ上にメニューを表示するため、このオブジェクトにはカーソルの現在の位置が含まれています。
さらに、フックにkeyDown
イベントリスナーをアタッチしcomponentDidMount
ます。入力したコマンドを状態で保存し、ユーザーがキーボードからブロックタイプを選択できるようにします。
command
状態プロパティが変更されるたびに、インポートされたmatchedSorter
関数を実行して、一致するブロックタイプをフィルタリングします。したがって、選択メニューには、コマンドに一致するブロックタイプのみが表示されます。
最後に、ユーザーが「Enter」を押すか、エントリをクリックすると、onSelect
小道具を介して受け取ったメソッドを実行します。
確かに、これは一度にたくさんのコードでした。しかし、それを段階的に実行すれば、うまくいけば理解できるはずです。
次に、物事をまとめることができます。選択メニューコンポーネントが配置されているので、次の場所に追加できますeditableBlock.js
。
// Imports | |
class EditableBlock extends React.Component { | |
constructor(props) { | |
super(props); | |
// ... | |
this.onKeyUpHandler = this.onKeyUpHandler.bind(this); | |
this.openSelectMenuHandler = this.openSelectMenuHandler.bind(this); | |
this.closeSelectMenuHandler = this.closeSelectMenuHandler.bind(this); | |
this.tagSelectionHandler = this.tagSelectionHandler.bind(this); | |
this.contentEditable = React.createRef(); | |
this.state = { | |
htmlBackup: null, | |
html: "", | |
tag: "p", | |
previousKey: "", | |
selectMenuIsOpen: false, | |
selectMenuPosition: { | |
x: null, | |
y: null | |
} | |
}; | |
} | |
// ... | |
onKeyUpHandler(e) { | |
if (e.key === "/") { | |
this.openSelectMenuHandler(); | |
} | |
} | |
openSelectMenuHandler() { | |
const { x, y } = getCaretCoordinates(); | |
this.setState({ | |
selectMenuIsOpen: true, | |
selectMenuPosition: { x, y } | |
}); | |
document.addEventListener("click", this.closeSelectMenuHandler); | |
} | |
closeSelectMenuHandler() { | |
this.setState({ | |
htmlBackup: null, | |
selectMenuIsOpen: false, | |
selectMenuPosition: { x: null, y: null } | |
}); | |
document.removeEventListener("click", this.closeSelectMenuHandler); | |
} | |
tagSelectionHandler(tag) { | |
this.setState({ tag: tag, html: this.state.htmlBackup }, () => { | |
setCaretToEnd(this.contentEditable.current); | |
this.closeSelectMenuHandler(); | |
}); | |
} | |
render() { | |
return ( | |
<> | |
{this.state.selectMenuIsOpen && ( | |
<SelectMenu | |
position={this.state.selectMenuPosition} | |
onSelect={this.tagSelectionHandler} | |
close={this.closeSelectMenuHandler} | |
/> | |
)} | |
<ContentEditable | |
className="Block" | |
innerRef={this.contentEditable} | |
html={this.state.html} | |
tagName={this.state.tag} | |
onChange={this.onChangeHandler} | |
onKeyDown={this.onKeyDownHandler} | |
onKeyUp={this.onKeyUpHandler} | |
/> | |
</> | |
); | |
} | |
} | |
export default EditableBlock; |
まず、SelectMenu
コンポーネントをインポートして条件付きでレンダリングします。状態プロパティによってその可視性を制御できますselectMenuIsOpen
。これをtrueに設定すると、ContentEditable
コンポーネントに加えてメニューがレンダリングされます。
次に、いつどこに表示するかを定義するロジックを実装しました。
メニューの開始と終了を処理する2つのメソッドを定義しました。それを開くと、最初に現在設定されているカーソルの座標を取得します。これは、カーソルの真上にあるメニューを開きたいためです。最後に、この位置を状態に保存し、selectMenuIsOpen
trueに設定して、クリックリスナーをアタッチします。
クリックリスナーは、メニュー自体にあるかメニューの外側にあるかに関係なく、画面がもう一度クリックされると、選択メニューを閉じる責任があります。両方のアクションで閉じる必要があります。
その場合、私たちのcloseSelectMenuHandler
方法はかなり簡単です。以前に設定した状態プロパティをリセットし、クリックリスナーを再度切り離します。
開始ハンドラーと終了ハンドラーを実装したら、実際にメニューを開くタイミングも定義する必要があります。
ユーザーの観点からは、ユーザーがを入力したらメニューを開きます/
。したがって、this.openSelectMenuHandler()
以前に定義したonKeyDownHandler
メソッドに呼び出しを追加するのは直感的ですよね?そこにあるので、ユーザーが/
キーを押したかどうかはすでに確認しています。
実際、そのために別のイベントリスナーを追加する必要があります。メニューは、ユーザーが/
キーを離したときにのみ表示されます。そうしないと、メニューの配置が正しく機能しません。したがって、そのための新しいkeyUp
イベントリスナーを追加します。
今、取るべき最後のステップは1つだけです。onSelect
関数を小道具として選択メニューコンポーネントに渡すことを覚えていますか?選択プロセスを機能させるには、編集可能なブロックコンポーネントでそれに関連するメソッドを定義する必要があります。
嬉しいことに、この方法はかなり簡単です。では、tagSelectionHandler
選択したブロックタイプをtag
引数として受け取ります。これを使用すると、tagName
状態プロパティを更新したり、HTMLバックアップ、つまりコマンドを入力せずにHTMLコンテンツを復元したりできます。
そのプロセスが終了したら、カーソルを編集可能なブロックに再度設定し、メニューを閉じます。
そして最後に、信じられないかもしれませんが…完了です🎉
注:カーソル座標を取得するために、再びヘルパー関数を使用しました。同じ機能を使用したい場合は、次のとおりです。
const getCaretCoordinates = () => { | |
let x, y; | |
const selection = window.getSelection(); | |
if (selection.rangeCount !== 0) { | |
const range = selection.getRangeAt(0).cloneRange(); | |
range.collapse(false); | |
const rect = range.getClientRects()[0]; | |
if (rect) { | |
x = rect.left; | |
y = rect.top; | |
} | |
} | |
return { x, y }; | |
}; |
Notionのテキストエディタのコア機能が再構築されたので、さらなる開発のための完璧な基盤ができました。アプリケーションをさらに改善するためにアプリケーションに追加できる機能はたくさんあります。
私は常に既存のアプリからインスピレーションを得て、自分でそれらの機能を構築できるかどうかを確認するのが好きです。概念と同じです。次に、2つの具体的な例を示します。
これまでのところ、ユーザーがを入力した場合にのみ選択メニューを表示します/
。しかし、ユーザーが既存のブロックのタイプをすばやく編集したい場合はどうでしょうか。または彼/彼女はブロックを削除したいですか?
ここでは、コンテキストメニューが便利です。ユーザーがブロックのコンテンツを選択するたびに、2つのオプションを含むメニューが表示されます。既存のブロックのタイプを別のタイプに変更するか、シングルクリックでブロック自体を削除します。
基盤となるブロック構造があるため、コンテンツをかなり簡単に再配置できます。たぶん、ユーザーは、この1つの段落をページの上部に配置する必要があることに気付いたでしょう。Notionでは、スムーズなドラッグアンドドロップ機能を使用してブロックを簡単に並べ替えることができます。そして、私たちもそれを行うことができます!
確かに、この記事では多くのことを取り上げました…しかし、Notionクローンを構築するアイデアとプロセスは、特にReactの初心者にとって、本当にクールでエキサイティングだと思います。したがって、私はあなたが必要とするすべてのものをあなたに提供したかったのです。
CodeSandboxに最終的なアプリケーションの実用的な例があります。また、この記事では取り上げなかったすべてのCSSスタイリングも含まれています。
さらに、より多くの機能(前のセクションで説明したような機能など)でアプリケーションを強化したい場合は、私のより高度なアプリケーションをご覧ください。それもインスピレーションの良い源かもしれません。
この高度なNotionCloneには、サーバー上のユーザーコンテンツを永続化するバックエンド部分も含まれています。Node / Expressの基本的なCRUD操作に触れたい場合に最適です!
ライブデモ: https://notion-clone.kmuenster.com/
Githubリポジトリ: https://github.com/konstantinmuenster/notion-clone
お気づきのように、私はアプリケーションでいくつかのヘルパー関数を使用しました。これらの関数は主に、カーソル座標の取得など、ブラウザでカーソルを操作するのに役立ちます。
これは開発中に大変苦労したことだったので、最近記事を公開しました。このトピックをさらに深く掘り下げたい場合は、次のとおりです。
コンテンツ編集可能な要素内のキャレットを見つける方法いつものように、読んでくれてありがとう!この記事全体で質問や問題が発生した場合は、コメントセクションでお知らせください。したがって、必要に応じて記事を更新および改善できます。フィードバックをいただければ幸いです。
ブックマークレットは、Webページを開く代わりにJavaScriptを実行するブラウザのブックマークです。これらは、ブックマークアプレット、お気に入り、またはJavaScriptブックマークとも呼ばれます。
タプルとは異なり、タプルに関する知識は固定されていません。実際、それは拡張することができます!タプルについてすべてを知っているわけではないに違いありません!それらについて学ぶことはたくさんあります。
アジェンダ:はじめに:TL;しかし、私は読むことができます:A / Bテスト、CloudFront、Lamba @ edgeについて既に知っている場合は、AWS Lambda @edgeを使用したA / Bテストに直接進んでください。A / Bテストとは何ですか?A / Bテストは、2つの異なるバージョンのウェブサイトに対するユーザーのエンゲージメントを比較することに焦点を当てたUX調査方法です。
関数型プログラミングが好きなのは、間違いを犯したり、うさぎの穴を掘ったりすることから、何年にもわたって数回節約できたからです。同じ入力が与えられた場合、出力は常に同じであることを知っていることは安心です。
Syncfusion Blazor File Uploadは、1つ以上のファイル、画像、ドキュメント、オーディオ、ビデオ、およびその他のファイルをサーバーにアップロードするためのコンポーネントです。これは、HTML5アップロードコンポーネント(<input type =” file”>)の拡張バージョンであり、複数のファイル選択、プログレスバー、自動アップロード、ドラッグアンドドロップ、フォルダー(ディレクトリ)アップロード、ファイルなどの豊富な機能セットを備えています。検証など。
トム・ヒドルストンとマイケル・ウォルドロンは、エピソード2の終わりにロキの女性版の公開について話します。
ケリー・ドッドは、彼女の元「RHOC」の共演者であるブラウンウィン・ウィンダム・バークを非難し、ブラボーのリアリティシリーズから解雇されたと非難しています。
ニックブレインとコマンダーローレンスは、「ハンドメイドの物語」のシーズン4フィナーレで6月を助けるために実際に問題を抱えていないかもしれません。
持続可能で環境への影響を低減した、ラボで作成されたペットフードの新時代が到来しています。しかし、ペット、そして彼らの人間はそれを好きになるでしょうか?
彼は創世記にほんの一瞬登場しますが、それでも彼はイエス・キリストの先駆者と見なされてきました。彼は本当に何でしたか、そしてどのようにして彼はイエスと関係を持つようになりましたか?
MavsのオーナーであるMarkCuban(l。)と元ヘッドコーチのRickCarlisleダラスでのDonnieNelson-RickCarlisleの時代は終わりました。
ペリカンが賢い場合、彼らはザイオンをオフシーズンの意思決定に関与させるでしょう。まだ2年しか経っていないため、ザイオンウィリアムソンは来年の夏まで延長資格がありませんが、ルイジアナバスケットボールの歴史の中で最も重要なシーズンの1つをすでに楽しみにしています。
「1分、私はもうライブ音楽を演奏するつもりかどうか疑問に思っています、そしてそれからいくつかのTikTokビデオが行き、すべてを完全に検証します」とジョージ・バージはPEOPLEに話します
ディスカバリードキュメンタリーシリーズのセレンゲティIIは、タンザニアの野生動物の生活を追っています。そして、たくさんのドラマを約束します。ここでスニークピークを取得します