メインコンテンツへスキップ
見出し画像
Photo byekakinonakagawa

【片手操作】ざっくりスマホで編集するためのテキストエディタを自作した話【iPhoneショートカット】【Scriptable】

    noteをはじめて、ほぼ毎日投稿していたのに気づいたら2日空いてました。

    年度末の繁忙期だったり年度末に番組をご卒業されたアベマプライムのお天気おねえさんほかのんの勇姿を毎日見るために忙しかったのですよ。

    さて、今回ご紹介するのはiPhoneショートカット&Scriptableで作成したざっくり確認・編集用のテキストエディタです。
    「人がどう思うか知らないが僕はこの機能が欲しい!」と思うものを詰め込んだ自己満足100%なツールです。ツール?

    操作画面はこんな感じです。
    下半分は市販のキーボードアプリですが。

    画像
    デザイン力が欲しい・・・

    今回のレシピ本体
    (レシピとは別にScriptableというアプリとコードの設定が必要となります。後述します)



    1.開発の動機

    chatGPTさんと毎日喧嘩しつつ開発ごっこを繰り広げているのですが、基本はパソコンで作業しています。

    ただ、最近は通勤中にもアイデアを思いついたりして電車内にてスマホ操作でchatGPTさんが作ってくれたスクリプトを確認したりしてます。

    その際にネックとなるのがやはりスマホの画面の小ささです。
    特に、パソコンだとコード記述などが変なところで改行されて理解するのが大変でして。
    (「画面横にして見ろよ」とか「ノートパソコンでやれよ」という声は僕には聞こえません(笑))

    画像
    右がchatGPT画面の出力
    左がそれをiPhone純正メモに貼ったもの

    【悩み】
    ✅もとの文章と、メモ帳に貼り付けたものの改行が合わない。
    →右のchatGPTさん作成のコードをメモ帳などに貼って編集するのですが、同じ内容なのに英文コードが改行されちゃうと素人な僕では修正指示された箇所がどこなのかを理解するのが大変。

    ✅タップ操作で指定箇所の部分コピーするのがめんどくさい。
    →実はこれはメモ帳での操作はもう慣れっこなのですが、今回のScriptableなどのアプリ上で直接編集する際の操作はアプリごとに特性があって大変。

    ✅ひと目で全体を確認したい。
    →スマホさんは文字拡大は充実しているのに、縮小は下限幅が狭い。字はめっちゃ小さくて良いのでなるべくスクロールしないで済みようにしたい。

    もう少しストレスなく編集できないものか?と思ってました。
    前回の開発でScriptableというiPhoneショートカットと連携してJavaScriptなどのコードを使用できるアプリに出会いまして、色々面白かったので今回もその機能を活用して仕組みを構築しました。

    このアプリが必要となります。



    2.仕組み

    実は大して書くべき仕組みはなく、Scriptable単体でも完結はするのですが、どうもこのツール安定性に難があるためショートカットの機能で欠点を補う仕組みとしております。

    【仕組み】
    ①iPhoneショートカットをScriptableで作成してスクリプトを呼び出す。
    ②画面が開き前回使用時に保存した文章が表示される。
    ③右側に編集機能のメニューとボタンがある。
     上段のモード名をタップするとモード切替。
     長押しすると前のモードに戻ります。
    ④編集した文章は保存が可能。1ファイル固定。
    ⑤終了ボタンを押すとショートカットに戻ってそこで終了処理を行う。

    ※この終了フローは前回開発のツールと同様です。Scriptableでの終了処理が異常処理が多いためこのフローとなっております、
    一応、試行錯誤してScriptableでも終了処理できるようにはなったのですがまだまだ不安定なのでこのフロー継続しております。


    3.設定方法

    【Scriptableの設定】
    ✅今回はショートカットレシピ自体の設定は不要ですが、Scriptableの設定および注意事項があります。

    ①Scriptableをダウンロードして開きます。右上の「+」ボタンを押すとコードを入力する画面が開きます。

    そこに以下のコードをコピペして、「Done」を押してください

    // ========================================
    // 設定
    // ========================================
    const FILE_NAME = "editor_memo9999996.txt"
    
    // 再起動検知(2回目起動なら即終了)
    if(args.queryParameters.exit === "1"){
      Pasteboard.copyString("NO_SAVE")
      Script.complete()
      return
    }
    // ========================================
    const fm = FileManager.local()
    const path = fm.joinPath(fm.documentsDirectory(), FILE_NAME)
    
    let initialText = ""
    if (fm.fileExists(path)) {
      initialText = fm.readString(path)
    }
    
    initialText = initialText
      .replace(/\\/g, "\\\\")
      .replace(/`/g, "\\`")
    
    // ========================================
    const html = `
    <!DOCTYPE html>
    <html>
    <head>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
    <style>
    body { margin:0; font-family: monospace; }
    
    #warning{
      height:16px;
      background:#fdd;
      color:#900;
      font-size:8px;
      display:flex;
      align-items:center;
      padding-left:6px;
    }
    
    #editorArea{ position:relative; height:calc(100vh - 16px); }
    
    #scroll {
      position:absolute;
      left:0; right:0;
      top:0; bottom:0;
      overflow-y:auto;
    }
    
    #editor {
      width:100%;
      min-height:100%;
      font-size:10px;
      padding:4px;
      box-sizing:border-box;
    }
    
    #overlay{
      position:fixed;
      right:10px;
      top:10px;
      display:flex;
      flex-direction:column;
      gap:6px;
      z-index:1000;
      width:140px;
    }
    
    #searchBox, #replaceBox{
      height:28px;
      font-size:10px;
      width:100%;
      box-sizing:border-box;
    }
    
    .btnRow{
      display:flex;
      justify-content:space-between;
    }
    
    #overlay button{
      width:48%;
      height:30px;
      font-size:10px;
    }
    
    #modeDisplay{
      width:100%;
      height:30px;
      font-size:10px;
      display:flex;
      align-items:center;
      justify-content:center;
      background:#ddd;
      border:1px solid #999;
      border-radius:4px;
      color:red;
    }
    </style>
    </head>
    
    <body>
    
    <div id="warning">
    ⚠ closeは使わないでください(右のメニューのボタンで終了)
    </div>
    
    <div id="editorArea">
      <div id="scroll">
        <textarea id="editor"></textarea>
      </div>
    </div>
    
    <div id="overlay">
    
      <div id="modeDisplay">終了モード</div>
    
      <input id="searchBox" placeholder="変更前">
      <input id="replaceBox" placeholder="変更後">
    
      <div class="btnRow">
        <button id="minusBtn">貼付</button>
        <button id="plusBtn">終了</button>
      </div>
    
    </div>
    
    <script>
    
    let editor = document.getElementById("editor")
    const overlay = document.getElementById("overlay")
    
    let follow = false
    let timer = null
    let lockViewport = false
    
    function updatePos(){
      if(lockViewport) return
      if(!follow) return
    
      const vv = window.visualViewport
      if(!vv) return
    
      const h = overlay.offsetHeight
      overlay.style.top = (vv.height + vv.offsetTop - h - 10) + "px"
    }
    const searchBox = document.getElementById("searchBox")
    const replaceBox = document.getElementById("replaceBox")
    
    const minusBtn = document.getElementById("minusBtn")
    const plusBtn = document.getElementById("plusBtn")
    const modeDisplay = document.getElementById("modeDisplay")
    
    editor.value = ""
    window.initialText = ""
    
    setTimeout(()=>{
      try{
        if(window.initialText.length > 500000){
          editor.value = "⚠ データが大きすぎて読み込めません"
        }else{
          editor.value = String(window.initialText || "")
        }
    
        // ★追加:フォーカス+カーソル先頭
        editor.focus()
        editor.selectionStart = 0
        editor.selectionEnd = 0
    
      }catch(e){
        editor.value = "⚠ 読み込みエラー"
      }
    },0)
    
    let fontSize = 10
    let holdMinus = null
    let holdPlus = null
    
    function updateLines(){
      editor.style.height="auto"
      editor.style.height=editor.scrollHeight+"px"
    }
    
    // ===== load =====
    function loadText(text){
      clearInterval(holdMinus)
      clearInterval(holdPlus)
    
      editor.value = text
      updateLines()
    
      setTimeout(()=>{
        editor.focus()
        editor.selectionStart = 0
        editor.selectionEnd = 0
      },0)
    }
    
    updateLines()
    bindEditorEvents()
    
    // ===== 追従追加 =====
    const scroll = document.getElementById("scroll")
    
    scroll.addEventListener("scroll", ()=>{
      follow = true
      updatePos()
      if(timer) clearTimeout(timer)
      timer = setTimeout(()=> follow = false, 300)
    })
    
    editor.addEventListener("touchstart", ()=>{
      follow = true
      setTimeout(updatePos,50)
    })
    
    if(window.visualViewport){
      visualViewport.addEventListener("resize", updatePos)
      visualViewport.addEventListener("scroll", updatePos)
    }
    
    // ===== モード =====
    const modes = ["終了モード","移動モード","選択モード","貼付モード","文字調整","検索/置換","保存/呼出"]
    let modeIndex = 0
    
    function updateUI(){
      const m = modes[modeIndex]
    
      modeDisplay.textContent = m
    
      if(m==="移動モード"){minusBtn.textContent="←";plusBtn.textContent="→"}
      else if(m==="終了モード"){minusBtn.textContent="貼付";plusBtn.textContent="終了"}
      else if(m==="保存/呼出"){minusBtn.textContent="呼出";plusBtn.textContent="保存"}
      else if(m==="選択モード"){minusBtn.textContent="選択-";plusBtn.textContent="選択+"}
      else if(m==="貼付モード"){minusBtn.textContent="終了";plusBtn.textContent="貼付"}
      else if(m==="文字調整"){minusBtn.textContent="文字-";plusBtn.textContent="文字+"}
      else{minusBtn.textContent="検索";plusBtn.textContent="置換"}
    }
    
    updateUI()
    
    let holdMode = null
    let longPressFired = false
    
    modeDisplay.addEventListener("touchstart", (e)=>{
      e.preventDefault()
      e.stopPropagation()
      lockViewport = true
    
      longPressFired = false
    
      holdMode = setTimeout(()=>{
        longPressFired = true
        modeIndex = (modeIndex - 1 + modes.length) % modes.length
        updateUI()
      },400)
    })
    
    modeDisplay.addEventListener("touchend", (e)=>{
      e.preventDefault()
      e.stopPropagation()
    
      if(holdMode) clearTimeout(holdMode)
    
      // 長押ししてない時だけ進む
      if(!longPressFired){
        modeIndex = (modeIndex + 1) % modes.length
        updateUI()
      }
    
      editor.focus()
      setTimeout(()=> lockViewport = false, 300)
    })
    
    // ===== カーソル =====
    function moveCursor(d){
      let p = editor.selectionStart + d
      p = Math.max(0, Math.min(editor.value.length, p))
      editor.selectionStart = p
      editor.selectionEnd = p
    }
    
    // ===== 検索 =====
    function findNext(){
      const word = searchBox.value
      if(!word) return
    
      const start = editor.selectionEnd
      let index = editor.value.indexOf(word, start)
    
      if(index === -1){
        index = editor.value.indexOf(word, 0)
      }
    
      if(index !== -1){
        editor.focus()
        editor.selectionStart = index
        editor.selectionEnd = index + word.length
    
        setTimeout(()=>{
          editor.focus()
          editor.selectionStart = index
          editor.selectionEnd = index + word.length
        },30)
      }
    }
    
    // ===== 置換 =====
    function replaceAll(){
      const word = searchBox.value
      const rep = replaceBox.value
      if(!word) return
      editor.value = editor.value.split(word).join(rep)
      updateLines()
    }
    
    // ===== 長押し(完全維持)=====
    minusBtn.addEventListener("touchstart", ()=>{
      clearInterval(holdMinus)
      holdMinus = setInterval(()=>{
        const m = modeDisplay.textContent
        setTimeout(()=>{
          if(m==="移動モード") moveCursor(-1)
    
          else if(m==="選択モード"){
      if(editor.selectionStart === editor.selectionEnd){
        // ★未選択なら全選択
    if(editor.value.length < 20000){
      setTimeout(()=>{
        editor.selectionStart = 0
        editor.selectionEnd = editor.value.length
      },0)
    }else{
      alert("文字数が多いため全選択不可。手動で対応してください")
    }
      }else{
        editor.selectionEnd = Math.max(editor.selectionStart, editor.selectionEnd - 1)
      }
    }
    
          else if(m==="文字調整"){
            fontSize--
            editor.style.fontSize = fontSize+"px"
          }
        },0)
      },80)
    })
    
    plusBtn.addEventListener("touchstart", ()=>{
      clearInterval(holdPlus)
      holdPlus = setInterval(()=>{
        const m = modeDisplay.textContent
        setTimeout(()=>{
          if(m==="移動モード") moveCursor(1)
    
          else if(m==="選択モード"){
            editor.selectionEnd = Math.min(editor.value.length, editor.selectionEnd + 1)
          }
    
          else if(m==="文字調整"){
            fontSize++
            editor.style.fontSize = fontSize+"px"
          }
        },0)
      },80)
    })
    
    // ===== touchend =====
    minusBtn.addEventListener("touchend", (e)=>{
      const m = modeDisplay.textContent
      if(m !== "検索/置換"){
        e.preventDefault()
        e.stopPropagation()
      }
      clearInterval(holdMinus)
    
      setTimeout(()=>{
        if(m==="移動モード") moveCursor(-1)
    
        else if(m==="終了モード") window.result="GET_CLIP"
    
        else if(m==="保存/呼出"){
      window.result = "LOAD"
    }
        else if(m==="貼付モード"){
        window.result="EXIT"
    //   window.location.href = "scriptable:///run?scriptName=edit&exit=1"
    }
        else if(m==="検索/置換") findNext()
      },0)
    })
    
    plusBtn.addEventListener("touchend", (e)=>{
      const m = modeDisplay.textContent
      if(m !== "検索/置換"){
        e.preventDefault()
        e.stopPropagation()
      }
      clearInterval(holdPlus)
    
      setTimeout(()=>{
        if(m==="移動モード") moveCursor(1)
    
    else if(m==="終了モード"){
      window.result="EXIT"
    //   window.location.href = "scriptable:///run?scriptName=edit&exit=1"
    
    }
        else if(m==="保存/呼出"){
      const text = editor.value
    
      if(text.length > 500000){
        alert("文字数が多すぎて保存できません")
        return
      }
    
      window.result = JSON.stringify({type:"save", text:text})
    
      const prev = modeDisplay.textContent
          modeDisplay.textContent = "保存OK"
          setTimeout(()=>{ modeDisplay.textContent = prev },500)
        }
    
        else if(m==="貼付モード") window.result="GET_CLIP"
    
        else if(m==="検索/置換") replaceAll()
      },0)
    })
    
    // ===== 貼付 =====
    function applyPasteText(t){
      const start = editor.selectionStart
      const end = editor.selectionEnd
    
      editor.value =
        editor.value.slice(0,start) +
        t +
        editor.value.slice(end)
    
      const pos = start + t.length
      editor.selectionStart = pos
      editor.selectionEnd = pos
    
      updateLines()
    
      setTimeout(()=>{
        editor.focus()
        editor.selectionStart = pos
        editor.selectionEnd = pos
      },30)
    }
    
    function bindEditorEvents(){
      editor.addEventListener("input", updateLines)
    }
    
    </script>
    </body>
    </html>
    `
    
    // ========================================
    const wv = new WebView()
    await wv.loadHTML(html)
    wv.present()
    
    // ★ここで注入(これが重要)
    await wv.evaluateJavaScript(
      "window.initialText = " + JSON.stringify(initialText),
      false
    )
    await wv.evaluateJavaScript(
      "editor.value = window.initialText",
      false
    )
    
    while(true){
      await new Promise(r=>Timer.schedule(1.0,false,r))
    
      let result = null
      try{
        result = await wv.evaluateJavaScript("window.result", false)
      }catch(e){
        break
      }
    
      if(!result) continue
    
      await wv.evaluateJavaScript("window.result=null", false)
    
      if(result==="EXIT"){
        Pasteboard.copyString("NO_SAVE")
        try{wv.dismiss()}catch(e){}
        Safari.open("shortcuts://run-shortcut?name=エディタ")
        Script.complete()
        return
      }
    
      if(result==="GET_CLIP"){
        const clip = Pasteboard.pasteString() || ""
    
        await wv.evaluateJavaScript(
          "applyPasteText(" + JSON.stringify(clip) + ")",
          false
        )
      }
    
      if(result==="LOAD"){
      const text = fm.fileExists(path) ? fm.readString(path) : ""
      await wv.evaluateJavaScript("loadText(" + JSON.stringify(text) + ")", false)
    }
    
    // ===== 追加(保存処理)=====
    let p = null
    try{ p = JSON.parse(result) }catch(e){}
    
    if(p && p.type === "save"){
      try{
        fm.writeString(path, p.text)
      }catch(e){
        console.log("save error")
      }
    }
    }

    ②初期状態のスクリプト名は「Untitled Script」となっているので、その名称を「edit」に変更します。スクリプトを長押しするとメニューが出てくるのでその中から「Rename」を選んでスクリプト名を変更してください。

    【注意点】
    ✅ダウンロードしたショートカット名「エディタ」で動作させることを前提にコードを作っております。もしショートカット名を変更した場合は上記コード内の以下の名称部分(太字部分)を変更してください。

    (☆修正箇所1☆)
     if(result==="EXIT"){
      Pasteboard.copyString("NO_SAVE")
      try{wv.dismiss()}catch(e){}
      Safari.open("shortcuts://run-shortcut?name=エディタ")
      Script.complete()
      return
      }

    (☆修正箇所2☆)
    ショートカット名変えた場合はショートカット側の以下の箇所も修正必要になります。

    画像
    名前一緒にすればよかった・・・

    これで設定は完了です。


    4.機能説明

    ✅モード切替
      右側に浮いているボタン群の上部のモード名をタップすることでモード切替できます。長押しでひとつ前の機能に戻ります。ボタン名はモードに対応して変化します。

      右メニューはカーソルの位置を追尾してごちゃごちゃ動くので気がちる(笑)

    ✅入力エリア
      文字の大きさ初期値を9pxとめちゃくちゃ小さくすることで大抵の文章は右端で改行もされず1行でおさまる想定。
    文字の大きさは自由に変更可能。

    画像
    文字小さくてタップ操作は難しいので
    後述の機能で操作すること前提です

    🔷移動モード : カーソル移動
    (ひとくち感想:これは大抵のキーボードアプリに実装されているからいらなかったかも・・・)

    画像
    矢印キーをタップで1マスカーソル移動
    矢印キーを長押しでカーソルが高速移動

    🔷選択モード : 文字選択範囲の拡張・縮小
    (ひとくち感想:個人的にはイチ押しの機能。選択していない状態で左ボタンを押すと全選択になる機能を一番使っている)

    画像
    右キーを押すとカーソル位置から右側に選択範囲1マス拡大
    左キーを押すと選択範囲1マス縮小
    長押しで高速で拡大/縮小
    選択していない状態で左ボタンを押すと全選択してくれる
    ※ただし、文章量が2万文字超えると自動選択機能は動かない

    🔷文字調整 : 文字の大きさを拡大 / 縮小
    (ひとくち感想:逆に普通のピンチ操作は一切できないのは好み分かれそう・・・)

    画像
    あり得ないくらい大きくできて、
    あり得ないくらい小さくできる(笑)

    🔷検索/置換 : 文章内の文字を検索/置換する機能。あいまい検索のみ。置換は上段の文字を下段の文字に置換。
    (ひとくち感想:右メニューがカーソル自動追尾する仕様のため画面が揺れて怖い)

    画像
    画像は置換後

    🔷保存/呼出 : 編集した文章を保存/呼出する機能。
    (ひとくち感想:Scriptableさんが元々大量データ処理するような用途のツールではないため文章量多いと保存失敗するので注意。おそらく10万文字限度)

    画像
    保存が成功するとボタン名称が「保存OK」に一瞬だけ変わる
    限度文字数はおそらく10万文字くらい?

    🔷貼付モード : コピーした内容をカーソル位置に貼付。
    (ひとくち感想:chatGPTさんにも怒られた超危険仕様。左ボタンにツール終了ボタンを割り当てている。理由は空いていたから(笑))

    画像
    間違って左ボタン押すと大変

    🔷終了モード : ツールを終了する
    (ひとくち感想:左上の「Close」ボタンを押すと異常終了になるため毎回存在する機能。左ボタンに「貼付」を割り当てている。理由は空いていたから)

    画像
    これなら貼付モードと終了モード統合すればよかった

    【問題点】
    このショートカットは以下の動作を行うと異常終了して、その直後に再開すると正常に動作しなくなります。

    ✅HTML画面の「Close」ボタンで終了した場合

    この動作を行った場合はAppスイッチャーを起動して「Scriptable」「ショートカット」のアイコンを上にスワイプして完全終了させると再びお使いになれます。


    5.欠点など

    ✅異常終了の多さ
    →色々がんばったのですが、文章量が増えると不安定になります。
     以下の現象が起きます。

     ・限度を超えた文章量を保存するとツールが破損します。
     (chatGPTさんいわく10万~50万文字。範囲広いな)。
      もし破損した場合はスクリプト最上部の
      以下の記述のファイル名を変更すれば復旧します。

    Const FILE_NAME = "editor_memo9999996.txt"

     ・自動で全範囲選択する場合も限度量あり。
      10万文字くらい。その場合は選択されないので手動対応。

     ・長時間放置していると落ちる。
      バックグラウンド処理なんて
      高尚なものはついていないので。
      マメに保存すべし。

    ✅右メニューの挙動
    →ボタンを常に押せる状態にしたいという目的があり、カーソル追尾の仕様にしたら暴れ馬のごとく動きます。しかもよくキーボード下に隠れる(泣)

    ✅フリック入力とタップ操作は慣れが必要
    →文字の大きさ調整できるから普通の入力も大丈夫だと思っていたら、動きが個性的すぎて困惑しました。一時編集目的で使うのが妥当かと。

    感想。iPhone純正メモアプリが良いな。


    6.まとめ

    相当、人を選ぶ仕様になってしまいました。

    おそらく僕の目的に合う既存のツールや手法もあると思うのですが、今回は衝動のまま作りました。

    あと、またchatGPTさんと大喧嘩して作っていて、徹夜明けの早朝にAI相手に大説教するという暴挙に出てしまいました。

    心なしかchatGPTさん不貞腐れていたような(笑)


    iPhoneショートカットアプリ関連








    この記事が参加している募集

     
     
    本当のお仕事は底辺の非正規労働者。東京都在住。現実逃避のためchatGPTと一緒に無駄に真面目な開発ごっこをしています。効率化とタスク管理を好む一方で非効率でも自動で動くカッコよさげな仕組みも好き。実はITスキル低め。趣味はAbemaの報道リアリティショー『アベマプライム』視聴。

    あなたへのおすすめ